shipping this slop
This commit is contained in:
parent
7117f17ece
commit
6e4069b42f
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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})`;
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./parser";
|
||||
export * from "./canvas";
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { IndexedDBAdapter } from "@/lib/storage/indexeddb-adapter";
|
||||
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
|
||||
|
||||
type StorageVersionRecord = {
|
||||
version: number;
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { MediaAssetData } from "@/lib/storage/types";
|
||||
import type { MediaAssetData } from "@/services/storage/types";
|
||||
|
||||
export type MediaType = "image" | "video" | "audio";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue