holy cow
This commit is contained in:
parent
0dddf4e13d
commit
0934db2aba
|
|
@ -0,0 +1,6 @@
|
|||
{"location":"use-timeline-zoom.ts:preventZoom","message":"document wheel preventDefault","data":"{\"isInTimeline\":true,\"isZoomKeyPressed\":true,\"isInContainer\":true,\"cancelable\":true,\"beforeDefaultPrevented\":false,\"afterDefaultPrevented\":true,\"shouldPrevent\":true}","timestamp":1768559405563,"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6"}
|
||||
{"location":"use-timeline-zoom.ts:handleWheel","message":"wheel event","data":"{\"ctrlKey\":true,\"metaKey\":false,\"shiftKey\":false,\"deltaX\":0,\"deltaY\":-100,\"isZoomGesture\":true,\"isHorizontalScrollGesture\":false,\"isInTimeline\":true,\"hasContainer\":true}","timestamp":1768559405563,"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1"}
|
||||
{"location":"use-timeline-zoom.ts:preventZoom","message":"document wheel preventDefault","data":"{\"isInTimeline\":true,\"isZoomKeyPressed\":true,\"isInContainer\":true,\"cancelable\":true,\"beforeDefaultPrevented\":false,\"afterDefaultPrevented\":true,\"shouldPrevent\":true}","timestamp":1768559411318,"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6"}
|
||||
{"location":"use-timeline-zoom.ts:handleWheel","message":"wheel event","data":"{\"ctrlKey\":true,\"metaKey\":false,\"shiftKey\":false,\"deltaX\":0,\"deltaY\":-100,\"isZoomGesture\":true,\"isHorizontalScrollGesture\":false,\"isInTimeline\":true,\"hasContainer\":true}","timestamp":1768559411320,"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1"}
|
||||
{"location":"use-timeline-zoom.ts:preventZoom","message":"document wheel preventDefault","data":"{\"isInTimeline\":true,\"isZoomKeyPressed\":true,\"isInContainer\":true,\"cancelable\":true,\"beforeDefaultPrevented\":false,\"afterDefaultPrevented\":true,\"shouldPrevent\":true}","timestamp":1768559412464,"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6"}
|
||||
{"location":"use-timeline-zoom.ts:handleWheel","message":"wheel event","data":"{\"ctrlKey\":true,\"metaKey\":false,\"shiftKey\":false,\"deltaX\":0,\"deltaY\":100,\"isZoomGesture\":true,\"isHorizontalScrollGesture\":false,\"isInTimeline\":true,\"hasContainer\":true}","timestamp":1768559412464,"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1"}
|
||||
|
|
@ -183,12 +183,12 @@ project-manager.ts
|
|||
isLoading
|
||||
isInitialized
|
||||
invalidProjectIds
|
||||
storageMigrationPromise: Promise<void> | null
|
||||
listeners
|
||||
migrationState: MigrationState
|
||||
constructor(private editor: EditorCore)
|
||||
async createNewProject({ name }: { name: string }): Promise<string>
|
||||
async loadProject({ id }: { id: string }): Promise<void>
|
||||
// ... and 21 more
|
||||
// ... and 22 more
|
||||
}
|
||||
|
||||
renderer-manager.ts
|
||||
|
|
@ -391,14 +391,16 @@ use-scroll-sync.ts
|
|||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
bookmarksScrollRef,
|
||||
}: UseScrollSyncProps)
|
||||
|
||||
use-selection-box.ts
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
playheadRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
}: UseSelectionBoxProps)
|
||||
|
||||
use-sound-search.ts
|
||||
|
|
@ -464,7 +466,6 @@ use-timeline-interactions.ts
|
|||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineInteractionsProps)
|
||||
|
|
@ -509,6 +510,39 @@ use-timeline-zoom.ts
|
|||
|
||||
## apps/web/src/lib
|
||||
|
||||
audio-utils.ts
|
||||
export type CollectedAudioElement = Omit<
|
||||
AudioElement,
|
||||
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceU...
|
||||
export function createAudioContext(): AudioContext
|
||||
export function collectAudioElements({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
audioContext,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaAssets: MediaAsset[];
|
||||
audioContext: AudioContext;
|
||||
}): Promise<CollectedAudioElement[]>
|
||||
export function collectAudioMixSources({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaAssets: MediaAsset[];
|
||||
}): Promise<AudioMixSource[]>
|
||||
export function createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
duration,
|
||||
sampleRate = 44100,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaAssets: MediaAsset[];
|
||||
duration: number;
|
||||
sampleRate?: number;
|
||||
}): Promise<AudioBuffer | null>
|
||||
|
||||
blog-query.ts
|
||||
export function getPosts()
|
||||
export function getTags()
|
||||
|
|
@ -785,9 +819,14 @@ transcription-utils.ts
|
|||
|
||||
utils.ts
|
||||
export function cn(...inputs: ClassValue[]): string
|
||||
export function formatDate({ date }: { date: Date }): string
|
||||
export function capitalizeFirstLetter({ string }: { string: string })
|
||||
export function generateUUID(): string
|
||||
export function isTypableDOMElement({ element }: { element: HTMLElement }): boolean
|
||||
export function isTypableDOMElement({
|
||||
element,
|
||||
}: {
|
||||
element: HTMLElement;
|
||||
}): boolean
|
||||
export function isAppleDevice(): boolean
|
||||
export function clamp({
|
||||
value,
|
||||
|
|
@ -1326,10 +1365,10 @@ drop-utils.ts
|
|||
}): number
|
||||
|
||||
element-utils.ts
|
||||
export function canHaveAudio(
|
||||
export function canElementHaveAudio(
|
||||
element: TimelineElement,
|
||||
)
|
||||
export function canBeHidden(
|
||||
export function canElementBeHidden(
|
||||
element: TimelineElement,
|
||||
)
|
||||
export function hasMediaId(
|
||||
|
|
@ -1410,6 +1449,12 @@ index.ts
|
|||
}): number
|
||||
|
||||
track-utils.ts
|
||||
export function canTracktHaveAudio(
|
||||
track: TimelineTrack,
|
||||
)
|
||||
export function canTrackBeHidden(
|
||||
track: TimelineTrack,
|
||||
)
|
||||
export function getTrackColor({ type }: { type: TrackType })
|
||||
export function getTrackClasses({ type }: { type: TrackType })
|
||||
export function getTrackHeight({ type }: { type: TrackType }): number
|
||||
|
|
@ -1425,6 +1470,7 @@ track-utils.ts
|
|||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
}): number
|
||||
export function isMainTrack(track: TimelineTrack)
|
||||
export function getMainTrack({
|
||||
tracks,
|
||||
}: {
|
||||
|
|
@ -1872,18 +1918,23 @@ timeline.ts
|
|||
type: "video"
|
||||
elements: (VideoElement | ImageElement)[]
|
||||
isMain: boolean
|
||||
muted: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
export interface TextTrack extends BaseTrack {
|
||||
type: "text"
|
||||
elements: TextElement[]
|
||||
hidden: boolean
|
||||
}
|
||||
export interface AudioTrack extends BaseTrack {
|
||||
type: "audio"
|
||||
elements: AudioElement[]
|
||||
muted: boolean
|
||||
}
|
||||
export interface StickerTrack extends BaseTrack {
|
||||
type: "sticker"
|
||||
elements: StickerElement[]
|
||||
hidden: boolean
|
||||
}
|
||||
export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack
|
||||
export interface Transform {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"/editor/[project_id]/page": "app/editor/[project_id]/page.js",
|
||||
"/projects/page": "app/projects/page.js"
|
||||
}
|
||||
|
|
@ -1 +1,5 @@
|
|||
{}
|
||||
{
|
||||
"/_app": "pages/_app.js",
|
||||
"/_document": "pages/_document.js",
|
||||
"/_error": "pages/_error.js"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -152,7 +152,6 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
const rawResult = await response.json();
|
||||
console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2));
|
||||
|
||||
const modalValidation = modalResponseSchema.safeParse(rawResult);
|
||||
if (!modalValidation.success) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import Image from "next/image";
|
|||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
|
||||
import { RenameProjectDialog } from "@/components/rename-project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MigrationDialog } from "@/components/editor/migration-dialog";
|
||||
import type { TProjectMetadata } from "@/types/project";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
|
@ -52,6 +53,12 @@ export default function ProjectsPage() {
|
|||
const router = useRouter();
|
||||
const editor = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.project.getIsInitialized()) {
|
||||
editor.project.loadAllProjects();
|
||||
}
|
||||
}, [editor.project]);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
try {
|
||||
const projectId = await editor.project.createNewProject({
|
||||
|
|
@ -147,6 +154,7 @@ export default function ProjectsPage() {
|
|||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<MigrationDialog />
|
||||
<div className="flex h-16 w-full items-center justify-between px-6 pt-2">
|
||||
<Link
|
||||
href="/"
|
||||
|
|
@ -636,7 +644,7 @@ function EmptyState({
|
|||
</p>
|
||||
</div>
|
||||
<Button size="lg" className="gap-2" onClick={onCreateProject}>
|
||||
<Plus className="size-4" />
|
||||
<Plus />
|
||||
Create your first project
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function TabBar() {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-[100] flex cursor-pointer flex-col items-center gap-0.5",
|
||||
"flex cursor-pointer flex-col items-center gap-0.5",
|
||||
activeTab === tabKey
|
||||
? "text-primary !opacity-100"
|
||||
: "text-muted-foreground",
|
||||
|
|
@ -73,7 +73,7 @@ export function TabBar() {
|
|||
variant="sidebar"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="dark:text-base-gray-950 text-sm font-medium leading-none text-black dark:text-white">
|
||||
<div className="text-foreground text-sm font-medium leading-none">
|
||||
{tab.label}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
|
|
@ -99,7 +99,7 @@ function FadeOverlay({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-0 right-0 z-[101] h-6 transition-opacity duration-200",
|
||||
"pointer-events-none absolute left-0 right-0 h-6 transition-opacity duration-200",
|
||||
direction === "top" && show
|
||||
? "from-panel top-0 bg-gradient-to-b to-transparent"
|
||||
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
|
||||
|
|
|
|||
|
|
@ -95,8 +95,6 @@ export function Captions() {
|
|||
|
||||
const { text, segments } = await transcriptionResponse.json();
|
||||
|
||||
console.log("Transcription completed:", { text, segments });
|
||||
|
||||
const shortCaptions: Array<{
|
||||
text: string;
|
||||
startTime: number;
|
||||
|
|
@ -132,7 +130,10 @@ export function Captions() {
|
|||
});
|
||||
});
|
||||
|
||||
const captionTrackId = editor.timeline.addTrack({ type: "text", index: 0 });
|
||||
const captionTrackId = editor.timeline.addTrack({
|
||||
type: "text",
|
||||
index: 0,
|
||||
});
|
||||
|
||||
shortCaptions.forEach((caption, index) => {
|
||||
editor.timeline.addElementToTrack({
|
||||
|
|
@ -148,14 +149,10 @@ export function Captions() {
|
|||
} as TextElement,
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ ${shortCaptions.length} short-form caption chunks added to timeline!`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Transcription failed:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
|
|
@ -164,7 +161,10 @@ export function Captions() {
|
|||
};
|
||||
|
||||
return (
|
||||
<BaseView ref={containerRef} className="flex flex-col justify-between h-full">
|
||||
<BaseView
|
||||
ref={containerRef}
|
||||
className="flex h-full flex-col justify-between"
|
||||
>
|
||||
<PropertyGroup title="Language">
|
||||
<LanguageSelect
|
||||
selectedCountry={selectedCountry}
|
||||
|
|
@ -176,8 +176,8 @@ export function Captions() {
|
|||
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -192,7 +192,7 @@ export function Captions() {
|
|||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing && <Loader2 className="mr-1 size-4 animate-spin" />}
|
||||
{isProcessing && <Loader2 className="mr-1 animate-spin" />}
|
||||
{isProcessing ? processingStep : "Generate transcript"}
|
||||
</Button>
|
||||
|
||||
|
|
@ -243,7 +243,7 @@ export function Captions() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<strong>True zero-knowledge privacy:</strong> Encryption keys
|
||||
are generated randomly in your browser and never stored
|
||||
anywhere. It's cryptographically impossible for us, our cloud
|
||||
|
|
|
|||
|
|
@ -231,9 +231,9 @@ export function MediaView() {
|
|||
className="w-full"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<CloudUpload className="size-4" />
|
||||
<CloudUpload />
|
||||
)}
|
||||
<span>Upload</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ function SoundEffectsView() {
|
|||
size="icon"
|
||||
className={cn(showCommercialOnly && "text-primary")}
|
||||
>
|
||||
<ListFilter className="w-4 h-4" />
|
||||
<ListFilter />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
|
|
@ -521,9 +521,9 @@ function AudioItem({
|
|||
<div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" />
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-5 h-5" />
|
||||
<PauseIcon className="size-5" />
|
||||
) : (
|
||||
<PlayIcon className="w-5 h-5" />
|
||||
<PlayIcon className="size-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -542,7 +542,7 @@ function AudioItem({
|
|||
onClick={handleAddToTimeline}
|
||||
title="Add to timeline"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
|
|
@ -555,7 +555,7 @@ function AudioItem({
|
|||
onClick={handleSaveClick}
|
||||
title={isSaved ? "Remove from saved" : "Save sound"}
|
||||
>
|
||||
<HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} />
|
||||
<HeartIcon className={`${isSaved ? "fill-current" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ export function MigrationDialog() {
|
|||
|
||||
if (!migrationState.isMigrating) return null;
|
||||
|
||||
const title = migrationState.projectName
|
||||
? "Updating project"
|
||||
: "Updating projects";
|
||||
const description = migrationState.projectName
|
||||
? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}`
|
||||
: `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`;
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
|
|
@ -24,11 +31,8 @@ export function MigrationDialog() {
|
|||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Updating project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upgrading "{migrationState.projectName}" from v
|
||||
{migrationState.fromVersion} to v{migrationState.toVersion}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-center py-4">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { canDeleteScene } from "@/lib/scene-utils";
|
||||
import { canDeleteScene, getMainScene } from "@/lib/scene-utils";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
|
|
@ -85,6 +85,11 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
|||
setIsSelectMode(false);
|
||||
};
|
||||
|
||||
const isMainSceneSelected = (() => {
|
||||
const mainScene = getMainScene({ scenes });
|
||||
return Boolean(mainScene?.id && selectedScenes.has(mainScene.id));
|
||||
})();
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>{children}</SheetTrigger>
|
||||
|
|
@ -114,15 +119,19 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
|||
<DeleteDialog
|
||||
count={selectedScenes.size}
|
||||
onDelete={handleDeleteSelected}
|
||||
disabled={Array.from(selectedScenes).some(
|
||||
(id) => scenes.find((s) => s.id === id)?.isMain,
|
||||
)}
|
||||
>
|
||||
<Button className="rounded-md" variant="destructive" size="sm">
|
||||
<Trash2 />
|
||||
Delete ({selectedScenes.size})
|
||||
</Button>
|
||||
</DeleteDialog>
|
||||
disabled={isMainSceneSelected}
|
||||
trigger={
|
||||
<Button
|
||||
className="rounded-md"
|
||||
variant="destructive"
|
||||
disabled={isMainSceneSelected}
|
||||
size="sm"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete ({selectedScenes.size})
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{scenes.length === 0 ? (
|
||||
|
|
@ -167,12 +176,12 @@ function DeleteDialog({
|
|||
count,
|
||||
onDelete,
|
||||
disabled,
|
||||
children,
|
||||
trigger,
|
||||
}: {
|
||||
count: number;
|
||||
onDelete: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
trigger: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
|
@ -183,7 +192,7 @@ function DeleteDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Scenes</DialogTitle>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { Bookmark } from "lucide-react";
|
||||
|
||||
interface TimelineBookmarksRowProps {
|
||||
zoomLevel: number;
|
||||
dynamicTimelineWidth: number;
|
||||
bookmarksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function TimelineBookmarksRow({
|
||||
zoomLevel,
|
||||
dynamicTimelineWidth,
|
||||
bookmarksScrollRef,
|
||||
handleWheel,
|
||||
handleTimelineContentClick,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineBookmarksRowProps) {
|
||||
const editor = useEditor();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mt-0.5 h-4 flex-1 overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
onClick={handleTimelineContentClick}
|
||||
data-bookmarks-area
|
||||
>
|
||||
<ScrollArea className="scrollbar-hidden w-full" ref={bookmarksScrollRef}>
|
||||
<div
|
||||
className="relative h-4 cursor-default select-none"
|
||||
style={{
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
>
|
||||
{activeScene.bookmarks.map((time: number, index: number) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-row-${index}`}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineBookmark({
|
||||
time,
|
||||
zoomLevel,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const handleBookmarkClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: React.MouseEvent<HTMLDivElement>;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
editor.playback.seek({ time });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 h-10 w-0.5 cursor-pointer"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
onClick={(event) => handleBookmarkClick({ event })}
|
||||
>
|
||||
<div className="text-primary absolute left-[-5px] top-[-1px]">
|
||||
<Bookmark className="fill-primary size-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { getDropLineY } from "@/lib/timeline/drop-utils";
|
||||
import type { TimelineTrack, DropTarget } from "@/types/timeline";
|
||||
|
||||
interface DragLineProps {
|
||||
dropTarget: DropTarget | null;
|
||||
tracks: TimelineTrack[];
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export function DragLine({ dropTarget, tracks, isVisible }: DragLineProps) {
|
||||
if (!isVisible || !dropTarget) return null;
|
||||
|
||||
const y = getDropLineY({ dropTarget, tracks });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-primary pointer-events-none absolute left-0 right-0 z-50 h-0.5"
|
||||
style={{ top: `${y}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ import {
|
|||
getTrackHeight,
|
||||
getCumulativeHeightBefore,
|
||||
getTotalTracksHeight,
|
||||
canTracktHaveAudio,
|
||||
canTrackBeHidden,
|
||||
} from "@/lib/timeline";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useScrollSync } from "@/hooks/use-scroll-sync";
|
||||
|
|
@ -33,16 +35,18 @@ import { useElementSelection } from "@/hooks/use-element-selection";
|
|||
import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { TimelineRuler } from "./timeline-ruler";
|
||||
import { DragLine } from "./drag-line";
|
||||
import { TimelineBookmarksRow } from "./bookmarks";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
|
||||
export function Timeline() {
|
||||
const tracksContainerHeight = { min: 200, max: 800 };
|
||||
const { snappingEnabled } = useTimelineStore();
|
||||
const { clearSelection, setSelection } = useElementSelection();
|
||||
const editor = useEditor();
|
||||
const timeline = editor.timeline;
|
||||
const tracks = timeline.getTracks();
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
|
||||
// Refs
|
||||
|
|
@ -54,6 +58,7 @@ export function Timeline() {
|
|||
const trackLabelsRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const bookmarksScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State
|
||||
const [isInTimeline, setIsInTimeline] = useState(false);
|
||||
|
|
@ -82,14 +87,11 @@ export function Timeline() {
|
|||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
|
||||
const timelineDuration = timeline.getTotalDuration() || 0;
|
||||
const paddedDuration =
|
||||
timelineDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS;
|
||||
const dynamicTimelineWidth = Math.max(
|
||||
(timeline.getTotalDuration() || 0) *
|
||||
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
|
||||
zoomLevel,
|
||||
(editor.playback.getCurrentTime() +
|
||||
TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
|
||||
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
|
||||
zoomLevel,
|
||||
paddedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
timelineRef.current?.clientWidth || 1000,
|
||||
);
|
||||
|
||||
|
|
@ -101,7 +103,7 @@ export function Timeline() {
|
|||
playheadRef,
|
||||
});
|
||||
|
||||
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
|
||||
const { dragProps } = useTimelineDragDrop({
|
||||
containerRef: tracksContainerRef,
|
||||
zoomLevel,
|
||||
});
|
||||
|
|
@ -110,13 +112,13 @@ export function Timeline() {
|
|||
selectionBox,
|
||||
handleMouseDown: handleSelectionMouseDown,
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
} = useSelectionBox({
|
||||
containerRef: tracksContainerRef,
|
||||
playheadRef,
|
||||
onSelectionComplete: (elements) => {
|
||||
setSelection(elements);
|
||||
},
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const showSnapIndicator =
|
||||
|
|
@ -130,7 +132,6 @@ export function Timeline() {
|
|||
zoomLevel,
|
||||
duration: timeline.getTotalDuration(),
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clearSelectedElements: clearSelection,
|
||||
seek,
|
||||
});
|
||||
|
|
@ -139,6 +140,7 @@ export function Timeline() {
|
|||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
bookmarksScrollRef,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -174,31 +176,45 @@ export function Timeline() {
|
|||
<SnapIndicator
|
||||
snapPoint={currentSnapPoint}
|
||||
zoomLevel={zoomLevel}
|
||||
tracks={timeline.getTracks()}
|
||||
tracks={tracks}
|
||||
timelineRef={timelineRef}
|
||||
trackLabelsRef={trackLabelsRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
isVisible={showSnapIndicator}
|
||||
/>
|
||||
<div className="bg-panel sticky top-0 z-10 flex">
|
||||
<div className="bg-panel flex w-28 shrink-0 items-center justify-between border-r px-3 py-2">
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
<div className="bg-panel sticky top-0 z-10 flex flex-col">
|
||||
<div className="flex">
|
||||
<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}
|
||||
rulerRef={rulerRef}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
handleWheel={handleWheel}
|
||||
handleSelectionMouseDown={handleSelectionMouseDown}
|
||||
handleTimelineContentClick={handleTimelineContentClick}
|
||||
handleRulerMouseDown={handleRulerMouseDown}
|
||||
/>
|
||||
<TimelineRuler
|
||||
zoomLevel={zoomLevel}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
rulerRef={rulerRef}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
handleWheel={handleWheel}
|
||||
handleTimelineContentClick={handleTimelineContentClick}
|
||||
handleRulerMouseDown={handleRulerMouseDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="bg-panel flex h-6 w-28 shrink-0 items-center justify-between border-r px-3">
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
<TimelineBookmarksRow
|
||||
zoomLevel={zoomLevel}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
bookmarksScrollRef={bookmarksScrollRef}
|
||||
handleWheel={handleWheel}
|
||||
handleTimelineContentClick={handleTimelineContentClick}
|
||||
handleRulerMouseDown={handleRulerMouseDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{timeline.getTracks().length > 0 && (
|
||||
{tracks.length > 0 && (
|
||||
<div
|
||||
ref={trackLabelsRef}
|
||||
className="z-100 bg-panel w-28 shrink-0 overflow-y-auto border-r"
|
||||
|
|
@ -206,7 +222,7 @@ export function Timeline() {
|
|||
>
|
||||
<ScrollArea className="h-full w-full" ref={trackLabelsScrollRef}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{timeline.getTracks().map((track) => (
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group flex items-center px-3"
|
||||
|
|
@ -215,26 +231,39 @@ export function Timeline() {
|
|||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||
{track.muted ? (
|
||||
<VolumeOff
|
||||
className="text-destructive h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Volume2
|
||||
{canTracktHaveAudio(track) && (
|
||||
<>
|
||||
{track.muted ? (
|
||||
<VolumeOff
|
||||
className="text-destructive h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Volume2
|
||||
className="text-muted-foreground h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canTrackBeHidden(track) && (
|
||||
<Eye
|
||||
className="text-muted-foreground h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
timeline.toggleTrackMute({
|
||||
timeline.toggleTrackHidden({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Eye className="text-muted-foreground h-4 w-4" />
|
||||
<TrackIcon track={track} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -265,50 +294,36 @@ export function Timeline() {
|
|||
containerRef={tracksContainerRef}
|
||||
isActive={selectionBox?.isActive || false}
|
||||
/>
|
||||
<DragLine
|
||||
dropTarget={dropTarget}
|
||||
tracks={timeline.getTracks()}
|
||||
isVisible={isDragOver}
|
||||
/>
|
||||
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
|
||||
<div
|
||||
className="relative flex-1"
|
||||
style={{
|
||||
height: `${Math.max(
|
||||
200,
|
||||
tracksContainerHeight.min,
|
||||
Math.min(
|
||||
800,
|
||||
getTotalTracksHeight({ tracks: timeline.getTracks() }),
|
||||
tracksContainerHeight.max,
|
||||
getTotalTracksHeight({ tracks }),
|
||||
),
|
||||
)}px`,
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
>
|
||||
{timeline.getTracks().length === 0 ? (
|
||||
{tracks.length === 0 ? (
|
||||
<div />
|
||||
) : (
|
||||
<>
|
||||
{timeline.getTracks().map((track, index) => (
|
||||
{tracks.map((track, index) => (
|
||||
<ContextMenu key={track.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: `${getCumulativeHeightBefore({
|
||||
tracks: timeline.getTracks(),
|
||||
tracks,
|
||||
trackIndex: index,
|
||||
})}px`,
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
!(e.target as HTMLElement).closest(
|
||||
".timeline-element",
|
||||
)
|
||||
) {
|
||||
clearSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TimelineTrackContent
|
||||
track={track}
|
||||
|
|
@ -331,7 +346,9 @@ export function Timeline() {
|
|||
});
|
||||
}}
|
||||
>
|
||||
{track.muted ? "Unmute Track" : "Mute Track"}
|
||||
{canTracktHaveAudio(track) && track.muted
|
||||
? "Unmute track"
|
||||
: "Mute track"}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={(e) => e.stopPropagation()}>
|
||||
Track settings (soon)
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
|||
import {
|
||||
getTrackClasses,
|
||||
getTrackHeight,
|
||||
canHaveAudio,
|
||||
canBeHidden,
|
||||
canElementHaveAudio,
|
||||
canElementBeHidden,
|
||||
hasMediaId,
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
|
|
@ -88,10 +88,8 @@ export function TimelineElement({
|
|||
selected.elementId === element.id && selected.trackId === track.id,
|
||||
);
|
||||
|
||||
const elementWidth = Math.max(
|
||||
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
|
||||
element.duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
);
|
||||
const elementWidth =
|
||||
element.duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const isBeingDragged = dragState.elementId === element.id;
|
||||
const elementStartTime =
|
||||
|
|
@ -118,7 +116,7 @@ export function TimelineElement({
|
|||
}
|
||||
};
|
||||
|
||||
const isMuted = canHaveAudio(element) && element.muted === true;
|
||||
const isMuted = canElementHaveAudio(element) && element.muted === true;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
|
|
@ -160,7 +158,7 @@ export function TimelineElement({
|
|||
selectedCount={selectedElements.length}
|
||||
onClick={(event) => handleAction({ action: "copy-selected", event })}
|
||||
/>
|
||||
{canHaveAudio(element) && hasAudio && (
|
||||
{canElementHaveAudio(element) && hasAudio && (
|
||||
<MuteMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
|
|
@ -172,7 +170,7 @@ export function TimelineElement({
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{canBeHidden(element) && (
|
||||
{canElementBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
|
|
@ -262,7 +260,7 @@ function ElementInner({
|
|||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${isBeingDragged ? "z-50" : "z-10"} ${canBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
)} ${isBeingDragged ? "z-50" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
onClick={(e) => onElementClick(e, element)}
|
||||
onMouseDown={(e) => onElementMouseDown(e, element)}
|
||||
onContextMenu={(e) => onElementMouseDown(e, element)}
|
||||
|
|
@ -276,7 +274,7 @@ function ElementInner({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{(hasAudio ? isMuted : canBeHidden(element) && element.hidden) && (
|
||||
{(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
{hasAudio ? (
|
||||
<VolumeX className="size-6 text-white" />
|
||||
|
|
@ -397,13 +395,15 @@ function ElementContent({
|
|||
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-[0.25rem] left-0 right-0 top-[0.25rem]"
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
top: isSelected ? "0.25rem" : "0rem",
|
||||
bottom: isSelected ? "0.25rem" : "0rem",
|
||||
}}
|
||||
aria-label={`Tiled ${mediaAsset.type === "image" ? "background" : "thumbnail"} of ${mediaAsset.name}`}
|
||||
/>
|
||||
|
|
@ -493,7 +493,7 @@ function VisibilityMenuItem({
|
|||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const isHidden = canBeHidden(element) && element.hidden;
|
||||
const isHidden = canElementBeHidden(element) && element.hidden;
|
||||
|
||||
const getIcon = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Bookmark } from "lucide-react";
|
||||
import { TimelineTick } from "./timeline-tick";
|
||||
|
||||
interface TimelineRulerProps {
|
||||
|
|
@ -10,7 +10,6 @@ interface TimelineRulerProps {
|
|||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleSelectionMouseDown: (e: React.MouseEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
|
@ -21,112 +20,80 @@ export function TimelineRuler({
|
|||
rulerRef,
|
||||
rulerScrollRef,
|
||||
handleWheel,
|
||||
handleSelectionMouseDown,
|
||||
handleTimelineContentClick,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineRulerProps) {
|
||||
const editor = useEditor();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const interval = getOptimalTimeInterval({ zoomLevel });
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const tickSpacingPixels = interval * pixelsPerSecond;
|
||||
const minLabelSpacingPixels = 120;
|
||||
const labelEvery = Math.max(
|
||||
1,
|
||||
Math.ceil(minLabelSpacingPixels / tickSpacingPixels),
|
||||
);
|
||||
const markerCount = Math.ceil(duration / interval) + 1;
|
||||
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
|
||||
const effectiveDuration = Math.max(duration, visibleDuration);
|
||||
const project = editor.project.getActiveOrNull();
|
||||
const fps = project?.settings.fps ?? DEFAULT_FPS;
|
||||
|
||||
const interval = getOptimalTimeInterval({ zoomLevel, fps });
|
||||
const markerCount = Math.ceil(effectiveDuration / interval) + 1;
|
||||
|
||||
const timelineTicks: Array<JSX.Element> = [];
|
||||
for (let markerIndex = 0; markerIndex < markerCount; markerIndex += 1) {
|
||||
const time = markerIndex * interval;
|
||||
if (time > duration) break;
|
||||
if (time > effectiveDuration) break;
|
||||
|
||||
timelineTicks.push(
|
||||
<TimelineTick
|
||||
key={markerIndex}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
interval={interval}
|
||||
shouldShowLabel={markerIndex % labelEvery === 0}
|
||||
/>,
|
||||
<TimelineTick key={markerIndex} time={time} zoomLevel={zoomLevel} />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-10 flex-1 overflow-hidden"
|
||||
className="relative h-4 flex-1 overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleSelectionMouseDown}
|
||||
onClick={handleTimelineContentClick}
|
||||
data-ruler-area
|
||||
>
|
||||
<ScrollArea className="w-full" ref={rulerScrollRef}>
|
||||
<ScrollArea className="scrollbar-hidden w-full" ref={rulerScrollRef}>
|
||||
<div
|
||||
ref={rulerRef}
|
||||
className="relative h-10 cursor-default select-none"
|
||||
className="relative h-4 cursor-default select-none"
|
||||
style={{
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
>
|
||||
{timelineTicks}
|
||||
|
||||
{activeScene.bookmarks.map((time: number, index: number) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-${index}`}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineBookmark({
|
||||
time,
|
||||
function getOptimalTimeInterval({
|
||||
zoomLevel,
|
||||
fps,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
fps: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const handleBookmarkClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: React.MouseEvent<HTMLDivElement>;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
editor.playback.seek({ time });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="!bg-primary absolute top-0 h-10 w-0.5 cursor-pointer"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
onClick={(event) => handleBookmarkClick({ event })}
|
||||
>
|
||||
<div className="text-primary absolute left-[-5px] top-[-1px]">
|
||||
<Bookmark className="fill-primary size-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOptimalTimeInterval({ zoomLevel }: { zoomLevel: number }) {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
if (pixelsPerSecond >= 200) return 0.1;
|
||||
if (pixelsPerSecond >= 100) return 0.5;
|
||||
if (pixelsPerSecond >= 50) return 1;
|
||||
if (pixelsPerSecond >= 25) return 2;
|
||||
if (pixelsPerSecond >= 12) return 5;
|
||||
if (pixelsPerSecond >= 6) return 10;
|
||||
return 30;
|
||||
const pixelsPerFrame = pixelsPerSecond / fps;
|
||||
const minPixelSpacing = 18;
|
||||
const minFrames = minPixelSpacing / pixelsPerFrame;
|
||||
const baseIntervals = [1, 3, 6, 12, 15, 30];
|
||||
const maxInterval = fps * 10;
|
||||
const niceIntervals: Array<number> = [...baseIntervals];
|
||||
|
||||
let currentInterval = baseIntervals[baseIntervals.length - 1];
|
||||
while (currentInterval < maxInterval) {
|
||||
currentInterval *= 2;
|
||||
niceIntervals.push(currentInterval);
|
||||
}
|
||||
|
||||
for (const intervalFrames of niceIntervals) {
|
||||
if (intervalFrames >= minFrames) {
|
||||
return intervalFrames / fps;
|
||||
}
|
||||
}
|
||||
|
||||
return niceIntervals[niceIntervals.length - 1] / fps;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,61 +5,15 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
|||
interface TimelineTickProps {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
interval: number;
|
||||
shouldShowLabel: boolean;
|
||||
}
|
||||
|
||||
export function TimelineTick({
|
||||
time,
|
||||
zoomLevel,
|
||||
interval,
|
||||
shouldShowLabel,
|
||||
}: TimelineTickProps) {
|
||||
export function TimelineTick({ time, zoomLevel }: TimelineTickProps) {
|
||||
return (
|
||||
<div
|
||||
className="border-muted-foreground/20 absolute top-0 h-4 border-l"
|
||||
className="border-muted-foreground/40 absolute top-1 h-1.5 border-l"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
>
|
||||
{shouldShowLabel ? (
|
||||
<span className="text-muted-foreground/70 absolute left-1 top-1 text-[0.6rem]">
|
||||
{formatTimelineTickLabel({ timeInSeconds: time, interval })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimelineTickLabel({
|
||||
timeInSeconds,
|
||||
interval,
|
||||
}: {
|
||||
timeInSeconds: number;
|
||||
interval: number;
|
||||
}): string {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const secondsRemainder = timeInSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
const paddedMinutes = minutes.toString().padStart(2, "0");
|
||||
const paddedSeconds = Math.floor(secondsRemainder)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
const paddedSeconds = Math.floor(secondsRemainder)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${minutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
if (interval >= 1) {
|
||||
return `${Math.floor(secondsRemainder)}s`;
|
||||
}
|
||||
|
||||
return `${secondsRemainder.toFixed(1)}s`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,43 +98,21 @@ function ToolbarLeftSection() {
|
|||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-play", event })
|
||||
}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={isPlaying ? <Pause /> : <Play />}
|
||||
tooltip={isPlaying ? "Pause" : "Play"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-play", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) => handleAction({ action: "goto-start", event })}
|
||||
>
|
||||
<SkipBack className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<SkipBack />}
|
||||
tooltip="Go to start"
|
||||
onClick={({ event }) => handleAction({ action: "goto-start", event })}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
<div className="bg-border mx-2 h-10 w-px" />
|
||||
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
|
|
@ -142,137 +120,76 @@ function ToolbarLeftSection() {
|
|||
fps={activeProject.settings.fps}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
<div className="bg-border mx-1 h-10 w-px" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Scissors />}
|
||||
tooltip="Split element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-selected", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "left",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowLeftToLine className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<ArrowLeftToLine />}
|
||||
tooltip="Split and keep left"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-selected-left", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "right",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowRightToLine className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<ArrowRightToLine />}
|
||||
tooltip="Split and keep right"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-selected-right", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" disabled type="button">
|
||||
<SplitSquareHorizontal className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<SplitSquareHorizontal />}
|
||||
tooltip="Coming soon" /* Separate audio */
|
||||
disabled={true}
|
||||
onClick={({ event }) => {}}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "duplicate-selected", event })
|
||||
}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Copy />}
|
||||
tooltip="Duplicate element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "duplicate-selected", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" disabled>
|
||||
<Snowflake className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F) (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Snowflake />}
|
||||
tooltip="Coming soon" /* Freeze frame */
|
||||
disabled={true}
|
||||
onClick={({ event }) => {}}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "delete-selected", event })
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete element (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Trash2 />}
|
||||
tooltip="Delete element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "delete-selected", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
}
|
||||
>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<Bookmark
|
||||
className={`size-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||
className={currentBookmarked ? "fill-primary text-primary" : ""}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
</TooltipContent>
|
||||
}
|
||||
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
|
@ -348,23 +265,17 @@ function ToolbarRightSection({
|
|||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" onClick={() => {}}>
|
||||
<Magnet className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto snapping</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Magnet />}
|
||||
tooltip="Auto snapping"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" onClick={() => {}}>
|
||||
<Link className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Enable Ripple Editing</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Link />}
|
||||
tooltip="Ripple editing"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
|
@ -376,7 +287,7 @@ function ToolbarRightSection({
|
|||
type="button"
|
||||
onClick={() => onZoom({ direction: "out" })}
|
||||
>
|
||||
<ZoomOut className="size-4" />
|
||||
<ZoomOut />
|
||||
</Button>
|
||||
<Slider
|
||||
className="w-24"
|
||||
|
|
@ -392,9 +303,38 @@ function ToolbarRightSection({
|
|||
type="button"
|
||||
onClick={() => onZoom({ direction: "in" })}
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
<ZoomIn />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
icon,
|
||||
tooltip,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={disabled ? "cursor-not-allowed opacity-50" : ""}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export function KeyboardShortcutsHelp() {
|
|||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="text" size="sm" className="gap-2">
|
||||
<Keyboard className="w-4 h-4" />
|
||||
<Keyboard />
|
||||
Shortcuts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ const ColorCircle = ({
|
|||
}) => (
|
||||
<div
|
||||
className={`absolute border-3 border-white rounded-full shadow-lg pointer-events-none ${
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4"
|
||||
size === "sm" ? "size-3" : "size-4"
|
||||
}`}
|
||||
style={{
|
||||
left: position.left,
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { motion } from "motion/react";
|
||||
import { ComponentType } from "react";
|
||||
|
||||
interface SponsorButtonProps {
|
||||
href: string;
|
||||
logo: ComponentType<{ className?: string }>;
|
||||
companyName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SponsorButton({
|
||||
href,
|
||||
logo: Logo,
|
||||
companyName,
|
||||
className = "",
|
||||
}: SponsorButtonProps) {
|
||||
return (
|
||||
<motion.a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md border border-border bg-background/5 backdrop-blur-xs hover:bg-background/10 transition-all duration-200 group shadow-lg ${className}`}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
Sponsored by
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="text-foreground/90 group-hover:text-foreground transition-colors">
|
||||
<Logo className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-medium text-foreground/90 group-hover:text-foreground transition-colors">
|
||||
{companyName}
|
||||
</span>
|
||||
</div>
|
||||
</motion.a>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,15 +37,14 @@ export const TRACK_HEIGHTS: Record<TrackType, number> = {
|
|||
export const TRACK_GAP = 4;
|
||||
|
||||
export const TIMELINE_CONSTANTS = {
|
||||
ELEMENT_MIN_WIDTH: 80,
|
||||
PIXELS_PER_SECOND: 50,
|
||||
TRACK_HEIGHT: 60,
|
||||
DEFAULT_ELEMENT_DURATION: 5,
|
||||
MIN_DURATION_SECONDS: 10,
|
||||
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // Padding ahead of playhead
|
||||
ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10],
|
||||
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: 10,
|
||||
ZOOM_MAX: 50,
|
||||
ZOOM_STEP: 0.1,
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import {
|
|||
} from "@/constants/project-constants";
|
||||
import { buildDefaultScene } from "@/lib/scene-utils";
|
||||
import { generateThumbnail } from "@/lib/media-processing-utils";
|
||||
import { CURRENT_VERSION, runMigrations } from "@/lib/migrations";
|
||||
import {
|
||||
CURRENT_STORAGE_VERSION,
|
||||
migrations,
|
||||
runStorageMigrations,
|
||||
} from "@/lib/migrations";
|
||||
|
||||
export interface MigrationState {
|
||||
isMigrating: boolean;
|
||||
|
|
@ -30,6 +34,7 @@ export class ProjectManager {
|
|||
private isLoading = true;
|
||||
private isInitialized = false;
|
||||
private invalidProjectIds = new Set<string>();
|
||||
private storageMigrationPromise: Promise<void> | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private migrationState: MigrationState = {
|
||||
isMigrating: false,
|
||||
|
|
@ -40,6 +45,43 @@ export class ProjectManager {
|
|||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
private async ensureStorageMigrations(): Promise<void> {
|
||||
if (this.storageMigrationPromise) {
|
||||
await this.storageMigrationPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.storageMigrationPromise = (async () => {
|
||||
let hasShownState = false;
|
||||
|
||||
await runStorageMigrations({
|
||||
migrations,
|
||||
callbacks: {
|
||||
onMigrationStart: ({ fromVersion, toVersion }) => {
|
||||
hasShownState = true;
|
||||
this.setMigrationState({
|
||||
isMigrating: true,
|
||||
fromVersion,
|
||||
toVersion,
|
||||
projectName: null,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (hasShownState) {
|
||||
this.setMigrationState({
|
||||
isMigrating: false,
|
||||
fromVersion: null,
|
||||
toVersion: null,
|
||||
projectName: null,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
await this.storageMigrationPromise;
|
||||
}
|
||||
|
||||
async createNewProject({ name }: { name: string }): Promise<string> {
|
||||
const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
|
||||
const newProject: TProject = {
|
||||
|
|
@ -59,7 +101,7 @@ export class ProjectManager {
|
|||
color: DEFAULT_COLOR,
|
||||
},
|
||||
},
|
||||
version: CURRENT_VERSION,
|
||||
version: CURRENT_STORAGE_VERSION,
|
||||
};
|
||||
|
||||
this.active = newProject;
|
||||
|
|
@ -88,6 +130,7 @@ export class ProjectManager {
|
|||
this.notify();
|
||||
}
|
||||
|
||||
await this.ensureStorageMigrations();
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.clearScenes();
|
||||
|
||||
|
|
@ -97,34 +140,7 @@ export class ProjectManager {
|
|||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
|
||||
let project = result.project;
|
||||
const migrationResult = runMigrations({ project });
|
||||
|
||||
if (migrationResult.migrated) {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.setMigrationState({
|
||||
isMigrating: true,
|
||||
fromVersion: migrationResult.fromVersion ?? null,
|
||||
toVersion: migrationResult.toVersion ?? null,
|
||||
projectName: project.metadata.name,
|
||||
});
|
||||
|
||||
project = migrationResult.project;
|
||||
await storageService.saveProject({ project });
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < 300) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300 - elapsed));
|
||||
}
|
||||
|
||||
this.setMigrationState({
|
||||
isMigrating: false,
|
||||
fromVersion: null,
|
||||
toVersion: null,
|
||||
projectName: null,
|
||||
});
|
||||
}
|
||||
const project = result.project;
|
||||
|
||||
this.active = project;
|
||||
this.notify();
|
||||
|
|
@ -173,6 +189,7 @@ export class ProjectManager {
|
|||
this.notify();
|
||||
}
|
||||
|
||||
await this.ensureStorageMigrations();
|
||||
try {
|
||||
const metadata = await storageService.loadAllProjectsMetadata();
|
||||
this.savedProjects = metadata;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,9 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import type { ExportOptions, ExportResult } from "@/types/export";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
|
||||
interface AudioElement {
|
||||
buffer: AudioBuffer;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
muted: boolean;
|
||||
}
|
||||
import { createTimelineAudioBuffer } from "@/lib/audio-utils";
|
||||
|
||||
export class RendererManager {
|
||||
private renderTree: RootNode | null = null;
|
||||
|
|
@ -58,7 +48,7 @@ export class RendererManager {
|
|||
let audioBuffer: AudioBuffer | null = null;
|
||||
if (includeAudio) {
|
||||
onProgress?.({ progress: 0.05 });
|
||||
audioBuffer = await this.createTimelineAudioBuffer({
|
||||
audioBuffer = await createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
duration,
|
||||
|
|
@ -128,121 +118,6 @@ export class RendererManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
duration,
|
||||
sampleRate = 44100,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaAssets: MediaAsset[];
|
||||
duration: number;
|
||||
sampleRate?: number;
|
||||
}): Promise<AudioBuffer | null> {
|
||||
const AudioContextClass =
|
||||
window.AudioContext ||
|
||||
(window as typeof window & { webkitAudioContext: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
const audioContext = new AudioContextClass();
|
||||
|
||||
const audioElements: AudioElement[] = [];
|
||||
const mediaMap = new Map<string, MediaAsset>(
|
||||
mediaAssets.map((m) => [m.id, m]),
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
if (track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (element.type !== "audio") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.duration <= 0) continue;
|
||||
|
||||
try {
|
||||
let audioBuffer: AudioBuffer;
|
||||
|
||||
if (element.sourceType === "upload") {
|
||||
const mediaAsset = mediaMap.get(element.mediaId);
|
||||
if (!mediaAsset || mediaAsset.type !== "audio") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const arrayBuffer = await mediaAsset.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 || track.muted || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Failed to decode audio:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputChannels = 2;
|
||||
const outputLength = Math.ceil(duration * sampleRate);
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
outputChannels,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
);
|
||||
|
||||
for (const element of audioElements) {
|
||||
if (element.muted) continue;
|
||||
|
||||
const {
|
||||
buffer,
|
||||
startTime,
|
||||
trimStart,
|
||||
duration: elementDuration,
|
||||
} = element;
|
||||
|
||||
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
|
||||
const sourceLengthSamples = Math.floor(
|
||||
elementDuration * buffer.sampleRate,
|
||||
);
|
||||
const outputStartSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
const resampleRatio = sampleRate / buffer.sampleRate;
|
||||
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
|
||||
|
||||
for (let channel = 0; channel < outputChannels; channel++) {
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
|
||||
const sourceData = buffer.getChannelData(sourceChannel);
|
||||
|
||||
for (let i = 0; i < resampledLength; i++) {
|
||||
const outputIndex = outputStartSample + i;
|
||||
if (outputIndex >= outputLength) break;
|
||||
|
||||
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
|
||||
if (sourceIndex >= sourceData.length) break;
|
||||
|
||||
outputData[outputIndex] += sourceData[sourceIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
removeBookmarkFromArray,
|
||||
isBookmarkAtTime,
|
||||
} from "@/lib/timeline/bookmark-utils";
|
||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
||||
|
||||
export class ScenesManager {
|
||||
private active: TScene | null = null;
|
||||
|
|
@ -220,14 +221,34 @@ export class ScenesManager {
|
|||
try {
|
||||
const result = await storageService.loadProject({ id: projectId });
|
||||
if (result?.project.scenes) {
|
||||
const { scenes: ensuredScenes, hasAddedMainTrack } =
|
||||
this.ensureScenesHaveMainTrack({
|
||||
scenes: result.project.scenes ?? [],
|
||||
});
|
||||
const currentScene = findCurrentScene({
|
||||
scenes: result.project.scenes,
|
||||
scenes: ensuredScenes,
|
||||
currentSceneId: result.project.currentSceneId,
|
||||
});
|
||||
|
||||
this.list = result.project.scenes;
|
||||
this.list = ensuredScenes;
|
||||
this.active = currentScene;
|
||||
this.notify();
|
||||
|
||||
if (hasAddedMainTrack) {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project scenes:", error);
|
||||
|
|
@ -245,23 +266,26 @@ export class ScenesManager {
|
|||
currentSceneId?: string;
|
||||
}): void {
|
||||
const ensuredScenes = ensureMainScene({ scenes });
|
||||
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
|
||||
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
|
||||
const currentScene = currentSceneId
|
||||
? ensuredScenes.find((s) => s.id === currentSceneId)
|
||||
? scenesWithMainTracks.find((s) => s.id === currentSceneId)
|
||||
: null;
|
||||
|
||||
const fallbackScene = getMainScene({ scenes: ensuredScenes });
|
||||
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
|
||||
|
||||
this.list = ensuredScenes;
|
||||
this.list = scenesWithMainTracks;
|
||||
this.active = currentScene || fallbackScene;
|
||||
this.notify();
|
||||
|
||||
if (ensuredScenes.length > scenes.length) {
|
||||
const hasAddedMainScene = ensuredScenes.length > scenes.length;
|
||||
if (hasAddedMainScene || hasAddedMainTrack) {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
scenes: scenesWithMainTracks,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
|
|
@ -372,6 +396,31 @@ export class ScenesManager {
|
|||
}
|
||||
}
|
||||
|
||||
private ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): {
|
||||
scenes: TScene[];
|
||||
hasAddedMainTrack: boolean;
|
||||
} {
|
||||
let hasAddedMainTrack = false;
|
||||
const ensuredScenes: TScene[] = [];
|
||||
|
||||
for (const scene of scenes) {
|
||||
const existingTracks = scene.tracks ?? [];
|
||||
const updatedTracks = ensureMainTrack({ tracks: existingTracks });
|
||||
if (updatedTracks !== existingTracks) {
|
||||
hasAddedMainTrack = true;
|
||||
ensuredScenes.push({
|
||||
...scene,
|
||||
tracks: updatedTracks,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
ensuredScenes.push(scene);
|
||||
}
|
||||
}
|
||||
|
||||
return { scenes: ensuredScenes, hasAddedMainTrack };
|
||||
}
|
||||
|
||||
private async updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId,
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ export function useElementInteraction({
|
|||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const isRightClick = event.button === 2;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ interface UseTimelineInteractionsProps {
|
|||
zoomLevel: number;
|
||||
duration: number;
|
||||
isSelecting: boolean;
|
||||
justFinishedSelecting: boolean;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
}
|
||||
|
|
@ -23,7 +22,6 @@ export function useTimelineInteractions({
|
|||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineInteractionsProps) {
|
||||
|
|
@ -71,7 +69,7 @@ export function useTimelineInteractions({
|
|||
|
||||
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) return false;
|
||||
|
||||
if (isSelecting || justFinishedSelecting) return false;
|
||||
if (isSelecting) return false;
|
||||
|
||||
if (target.closest(".timeline-element")) return false;
|
||||
|
||||
|
|
@ -84,7 +82,7 @@ export function useTimelineInteractions({
|
|||
|
||||
return true;
|
||||
},
|
||||
[isSelecting, justFinishedSelecting, clearSelectedElements, playheadRef],
|
||||
[isSelecting, clearSelectedElements, playheadRef],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
|
|
|
|||
|
|
@ -91,25 +91,6 @@ export function useTimelinePlayhead({
|
|||
const fps = activeProject.settings.fps;
|
||||
const time = snapTimeToFrame({ time: rawTime, fps });
|
||||
|
||||
// debug logging
|
||||
if (rawX < 0 || x !== rawX) {
|
||||
console.log(
|
||||
"PLAYHEAD DEBUG:",
|
||||
JSON.stringify({
|
||||
mouseX: event.clientX,
|
||||
rulerLeft: rect.left,
|
||||
rawX,
|
||||
constrainedX: x,
|
||||
timelineContentWidth,
|
||||
rawTime,
|
||||
finalTime: time,
|
||||
duration,
|
||||
zoomLevel,
|
||||
playheadPx: time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
|
||||
|
|
|
|||
|
|
@ -35,14 +35,14 @@ export function useTimelineZoom({
|
|||
|
||||
// pinch-zoom (ctrl/meta + wheel)
|
||||
if (isZoomGesture) {
|
||||
event.preventDefault();
|
||||
const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
|
||||
setZoomLevel((prev) =>
|
||||
Math.max(
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom = Math.max(
|
||||
TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
|
||||
),
|
||||
);
|
||||
);
|
||||
return nextZoom;
|
||||
});
|
||||
// for horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
return;
|
||||
|
|
@ -51,25 +51,25 @@ export function useTimelineZoom({
|
|||
|
||||
// prevent browser zoom in the timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = ({
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
target,
|
||||
preventDefault,
|
||||
}: WheelEvent) => {
|
||||
if (
|
||||
isInTimeline &&
|
||||
(ctrlKey || metaKey) &&
|
||||
containerRef.current?.contains(target as Node)
|
||||
) {
|
||||
preventDefault();
|
||||
const preventZoom = (event: WheelEvent) => {
|
||||
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
|
||||
const isInContainer = containerRef.current?.contains(
|
||||
event.target as Node,
|
||||
);
|
||||
const shouldPrevent =
|
||||
isInTimeline && isZoomKeyPressed && Boolean(isInContainer);
|
||||
if (shouldPrevent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", preventZoom, { passive: false });
|
||||
document.addEventListener("wheel", preventZoom, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom);
|
||||
document.removeEventListener("wheel", preventZoom, { capture: true });
|
||||
};
|
||||
}, [isInTimeline, containerRef]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
TAction,
|
||||
TActionFunc,
|
||||
|
|
@ -14,7 +14,7 @@ export function useActionHandler<A extends TAction>(
|
|||
isActive: TActionHandlerOptions,
|
||||
) {
|
||||
const handlerRef = useRef(handler);
|
||||
const [isBound, setIsBound] = useState(false);
|
||||
const isBoundRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
|
|
@ -32,36 +32,34 @@ export function useActionHandler<A extends TAction>(
|
|||
isActive === undefined ||
|
||||
(typeof isActive === "boolean" ? isActive : isActive.current);
|
||||
|
||||
if (shouldBind && !isBound) {
|
||||
if (shouldBind && !isBoundRef.current) {
|
||||
bindAction(action, stableHandler);
|
||||
setIsBound(true);
|
||||
} else if (!shouldBind && isBound) {
|
||||
isBoundRef.current = true;
|
||||
} else if (!shouldBind && isBoundRef.current) {
|
||||
unbindAction(action, stableHandler);
|
||||
setIsBound(false);
|
||||
isBoundRef.current = false;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (isBound) {
|
||||
unbindAction(action, stableHandler);
|
||||
setIsBound(false);
|
||||
}
|
||||
unbindAction(action, stableHandler);
|
||||
isBoundRef.current = false;
|
||||
};
|
||||
}, [action, stableHandler, isActive, isBound]);
|
||||
}, [action, stableHandler, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && typeof isActive === "object" && "current" in isActive) {
|
||||
const interval = setInterval(() => {
|
||||
const shouldBind = isActive.current;
|
||||
if (shouldBind !== isBound) {
|
||||
if (shouldBind !== isBoundRef.current) {
|
||||
if (shouldBind) {
|
||||
bindAction(action, stableHandler);
|
||||
} else {
|
||||
unbindAction(action, stableHandler);
|
||||
}
|
||||
setIsBound(shouldBind);
|
||||
isBoundRef.current = shouldBind;
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [action, stableHandler, isActive, isBound]);
|
||||
}, [action, stableHandler, isActive]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,38 @@ export function useEditorActions() {
|
|||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"split-selected-left",
|
||||
() => {
|
||||
const splitElementIds = 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,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"split-selected-right",
|
||||
() => {
|
||||
const splitElementIds = 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,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"delete-selected",
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -4,22 +4,27 @@ interface UseScrollSyncProps {
|
|||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
|
||||
bookmarksScrollRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useScrollSync({
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
bookmarksScrollRef,
|
||||
}: UseScrollSyncProps) {
|
||||
const isUpdatingRef = useRef(false);
|
||||
const lastRulerSync = useRef(0);
|
||||
const lastTracksSync = useRef(0);
|
||||
const lastVerticalSync = useRef(0);
|
||||
const lastBookmarksSync = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
const trackLabelsViewport = trackLabelsScrollRef?.current;
|
||||
const bookmarksViewport = bookmarksScrollRef?.current;
|
||||
let handleBookmarksScroll: (() => void) | null = null;
|
||||
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
|
|
@ -29,6 +34,9 @@ export function useScrollSync({
|
|||
lastRulerSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
tracksViewport.scrollLeft = rulerViewport.scrollLeft;
|
||||
if (bookmarksViewport) {
|
||||
bookmarksViewport.scrollLeft = rulerViewport.scrollLeft;
|
||||
}
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
|
|
@ -38,12 +46,30 @@ export function useScrollSync({
|
|||
lastTracksSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft;
|
||||
if (bookmarksViewport) {
|
||||
bookmarksViewport.scrollLeft = tracksViewport.scrollLeft;
|
||||
}
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
rulerViewport.addEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.addEventListener("scroll", handleTracksScroll);
|
||||
|
||||
if (bookmarksViewport) {
|
||||
handleBookmarksScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastBookmarksSync.current < 16)
|
||||
return;
|
||||
lastBookmarksSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
tracksViewport.scrollLeft = bookmarksViewport.scrollLeft;
|
||||
rulerViewport.scrollLeft = bookmarksViewport.scrollLeft;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
|
||||
}
|
||||
|
||||
if (trackLabelsViewport) {
|
||||
const handleTrackLabelsScroll = () => {
|
||||
const now = Date.now();
|
||||
|
|
@ -71,6 +97,12 @@ export function useScrollSync({
|
|||
return () => {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
if (bookmarksViewport && handleBookmarksScroll) {
|
||||
bookmarksViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleBookmarksScroll,
|
||||
);
|
||||
}
|
||||
trackLabelsViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleTrackLabelsScroll,
|
||||
|
|
@ -85,8 +117,14 @@ export function useScrollSync({
|
|||
return () => {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
if (bookmarksViewport && handleBookmarksScroll) {
|
||||
bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
|
||||
}
|
||||
};
|
||||
}, [rulerScrollRef, tracksScrollRef, trackLabelsScrollRef]);
|
||||
}, [
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
bookmarksScrollRef,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
|
||||
import { useEditor } from "./use-editor";
|
||||
|
||||
interface UseSelectionBoxProps {
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
playheadRef?: React.RefObject<HTMLElement>;
|
||||
onSelectionComplete: (
|
||||
elements: { trackId: string; elementId: string }[]
|
||||
elements: { trackId: string; elementId: string }[],
|
||||
) => void;
|
||||
isEnabled?: boolean;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
interface SelectionBoxState {
|
||||
|
|
@ -15,169 +19,182 @@ interface SelectionBoxState {
|
|||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface SelectionRectangle {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
function getNormalizedRectangle({
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
return {
|
||||
left: Math.min(startPos.x, endPos.x),
|
||||
top: Math.min(startPos.y, endPos.y),
|
||||
right: Math.max(startPos.x, endPos.x),
|
||||
bottom: Math.max(startPos.y, endPos.y),
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
|
||||
const adjustedStart = {
|
||||
x: startPos.x - containerRect.left + scrollLeft,
|
||||
y: startPos.y - containerRect.top + scrollTop,
|
||||
};
|
||||
const adjustedEnd = {
|
||||
x: endPos.x - containerRect.left + scrollLeft,
|
||||
y: endPos.y - containerRect.top + scrollTop,
|
||||
};
|
||||
|
||||
return getNormalizedRectangle({
|
||||
startPos: adjustedStart,
|
||||
endPos: adjustedEnd,
|
||||
});
|
||||
}
|
||||
|
||||
function isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
}: {
|
||||
elementRectangle: SelectionRectangle;
|
||||
selectionRectangle: SelectionRectangle;
|
||||
}): boolean {
|
||||
return !(
|
||||
elementRectangle.right < selectionRectangle.left ||
|
||||
elementRectangle.left > selectionRectangle.right ||
|
||||
elementRectangle.bottom < selectionRectangle.top ||
|
||||
elementRectangle.top > selectionRectangle.bottom
|
||||
);
|
||||
}
|
||||
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
playheadRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
}: UseSelectionBoxProps) {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
||||
|
||||
// Mouse down handler to start selection
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
({ clientX, clientY }: React.MouseEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
// Only start selection on empty space clicks
|
||||
if ((e.target as HTMLElement).closest(".timeline-element")) {
|
||||
return;
|
||||
}
|
||||
if (playheadRef?.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
|
||||
return;
|
||||
}
|
||||
// Don't start selection when clicking in the ruler area - this interferes with playhead dragging
|
||||
if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionBox({
|
||||
startPos: { x: e.clientX, y: e.clientY },
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
isActive: false, // Will become active when mouse moves
|
||||
startPos: { x: clientX, y: clientY },
|
||||
currentPos: { x: clientX, y: clientY },
|
||||
isActive: false,
|
||||
});
|
||||
},
|
||||
[isEnabled, playheadRef]
|
||||
[isEnabled],
|
||||
);
|
||||
|
||||
// Function to select elements within the selection box
|
||||
const selectElementsInBox = useCallback(
|
||||
(startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
|
||||
({
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate selection rectangle in container coordinates
|
||||
const startX = startPos.x - containerRect.left;
|
||||
const startY = startPos.y - containerRect.top;
|
||||
const endX = endPos.x - containerRect.left;
|
||||
const endY = endPos.y - containerRect.top;
|
||||
|
||||
const selectionRect = {
|
||||
left: Math.min(startX, endX),
|
||||
top: Math.min(startY, endY),
|
||||
right: Math.max(startX, endX),
|
||||
bottom: Math.max(startY, endY),
|
||||
};
|
||||
|
||||
// Find all timeline elements within the selection rectangle
|
||||
const timelineElements = container.querySelectorAll(".timeline-element");
|
||||
|
||||
const selectionRectangle = getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer: tracksScrollRef.current,
|
||||
startPos,
|
||||
endPos,
|
||||
});
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
|
||||
timelineElements.forEach((element) => {
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
// Use absolute coordinates for more accurate intersection detection
|
||||
const elementAbsolute = {
|
||||
left: elementRect.left,
|
||||
top: elementRect.top,
|
||||
right: elementRect.right,
|
||||
bottom: elementRect.bottom,
|
||||
};
|
||||
for (const [trackIndex, track] of tracks.entries()) {
|
||||
const trackTop = getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
});
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const elementTop = trackTop;
|
||||
const elementBottom = trackTop + trackHeight;
|
||||
|
||||
const selectionAbsolute = {
|
||||
left: startPos.x,
|
||||
top: startPos.y,
|
||||
right: endPos.x,
|
||||
bottom: endPos.y,
|
||||
};
|
||||
for (const element of track.elements) {
|
||||
const elementLeft = element.startTime * pixelsPerSecond;
|
||||
const elementRight = elementLeft + element.duration * pixelsPerSecond;
|
||||
|
||||
// Normalize selection rectangle (handle dragging in any direction)
|
||||
const normalizedSelection = {
|
||||
left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
|
||||
top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
|
||||
bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
};
|
||||
const elementRectangle = {
|
||||
left: elementLeft,
|
||||
top: elementTop,
|
||||
right: elementRight,
|
||||
bottom: elementBottom,
|
||||
};
|
||||
|
||||
const elementId = element.getAttribute("data-element-id");
|
||||
const trackId = element.getAttribute("data-track-id");
|
||||
const intersects = isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
});
|
||||
|
||||
// Check if element intersects with selection rectangle (any overlap)
|
||||
// Using absolute coordinates for more precise detection
|
||||
const intersects = !(
|
||||
elementAbsolute.right < normalizedSelection.left ||
|
||||
elementAbsolute.left > normalizedSelection.right ||
|
||||
elementAbsolute.bottom < normalizedSelection.top ||
|
||||
elementAbsolute.top > normalizedSelection.bottom
|
||||
);
|
||||
|
||||
if (intersects && elementId && trackId) {
|
||||
selectedElements.push({ trackId, elementId });
|
||||
if (intersects) {
|
||||
selectedElements.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Always call the callback - with elements or empty array to clear selection
|
||||
console.log(
|
||||
JSON.stringify({ selectElementsInBox: selectedElements.length })
|
||||
);
|
||||
}
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete]
|
||||
[containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
|
||||
);
|
||||
|
||||
// Effect to track selection box movement
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
|
||||
const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
|
||||
|
||||
// Start selection if mouse moved more than 5px
|
||||
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
||||
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
|
||||
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
|
||||
const shouldActivate = deltaX > 5 || deltaY > 5;
|
||||
|
||||
const newSelectionBox = {
|
||||
...selectionBox,
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
currentPos: { x: clientX, y: clientY },
|
||||
isActive: shouldActivate || selectionBox.isActive,
|
||||
};
|
||||
|
||||
setSelectionBox(newSelectionBox);
|
||||
|
||||
// Real-time visual feedback: update selection as we drag
|
||||
if (newSelectionBox.isActive) {
|
||||
selectElementsInBox(
|
||||
newSelectionBox.startPos,
|
||||
newSelectionBox.currentPos
|
||||
);
|
||||
selectElementsInBox({
|
||||
startPos: newSelectionBox.startPos,
|
||||
endPos: newSelectionBox.currentPos,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
console.log(
|
||||
JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
|
||||
);
|
||||
|
||||
// If we had an active selection, mark that we just finished selecting
|
||||
if (selectionBox?.isActive) {
|
||||
console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(true);
|
||||
// Clear the flag after a short delay to allow click events to check it
|
||||
setTimeout(() => {
|
||||
console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(false);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Don't call selectElementsInBox again - real-time selection already handled it
|
||||
// Just clean up the selection box visual
|
||||
setSelectionBox(null);
|
||||
};
|
||||
|
||||
|
|
@ -210,6 +227,5 @@ export function useSelectionBox({
|
|||
selectionBox,
|
||||
handleMouseDown,
|
||||
isSelecting: selectionBox?.isActive || false,
|
||||
justFinishedSelecting,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,16 @@ export const ACTIONS = {
|
|||
category: "editing",
|
||||
defaultShortcuts: ["s"],
|
||||
},
|
||||
"split-selected-left": {
|
||||
description: "Split element at playhead and keep left",
|
||||
category: "editing",
|
||||
defaultShortcuts: ["q"],
|
||||
},
|
||||
"split-selected-right": {
|
||||
description: "Split element at playhead and keep right",
|
||||
category: "editing",
|
||||
defaultShortcuts: ["w"],
|
||||
},
|
||||
"delete-selected": {
|
||||
description: "Delete selected elements",
|
||||
category: "editing",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
import type {
|
||||
AudioElement,
|
||||
LibraryAudioElement,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
} from "@/types/timeline";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
|
||||
import { canTracktHaveAudio } from "@/lib/timeline";
|
||||
import { mediaSupportsAudio } from "@/lib/media-utils";
|
||||
|
||||
export type CollectedAudioElement = Omit<
|
||||
AudioElement,
|
||||
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
|
||||
>;
|
||||
|
||||
export function createAudioContext(): AudioContext {
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext ||
|
||||
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
|
||||
return new AudioContextConstructor();
|
||||
}
|
||||
|
||||
export async function collectAudioElements({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
audioContext,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaAssets: MediaAsset[];
|
||||
audioContext: AudioContext;
|
||||
}): Promise<CollectedAudioElement[]> {
|
||||
const audioElements: CollectedAudioElement[] = [];
|
||||
const mediaMap = new Map<string, MediaAsset>(
|
||||
mediaAssets.map((media) => [media.id, media]),
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
if (canTracktHaveAudio(track) && track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return audioElements;
|
||||
}
|
||||
|
||||
interface AudioMixSource {
|
||||
file: File;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
}
|
||||
|
||||
async function fetchLibraryAudioSource({
|
||||
element,
|
||||
}: {
|
||||
element: LibraryAudioElement;
|
||||
}): Promise<AudioMixSource | null> {
|
||||
try {
|
||||
const response = await fetch(element.sourceUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Library audio fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `${element.name}.mp3`, {
|
||||
type: "audio/mpeg",
|
||||
});
|
||||
|
||||
return {
|
||||
file,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch library audio:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectMediaAudioSource({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAsset: MediaAsset;
|
||||
}): AudioMixSource {
|
||||
return {
|
||||
file: mediaAsset.file,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectAudioMixSources({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaAssets: MediaAsset[];
|
||||
}): Promise<AudioMixSource[]> {
|
||||
const audioMixSources: AudioMixSource[] = [];
|
||||
const mediaMap = new Map<string, MediaAsset>(
|
||||
mediaAssets.map((asset) => [asset.id, asset]),
|
||||
);
|
||||
const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
|
||||
|
||||
for (const track of tracks) {
|
||||
if (canTracktHaveAudio(track) && track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (!canElementHaveAudio(element)) continue;
|
||||
|
||||
if (element.type === "audio") {
|
||||
if (element.sourceType === "upload") {
|
||||
const mediaAsset = mediaMap.get(element.mediaId);
|
||||
if (!mediaAsset) continue;
|
||||
|
||||
audioMixSources.push(
|
||||
collectMediaAudioSource({ element, mediaAsset }),
|
||||
);
|
||||
} else {
|
||||
pendingLibrarySources.push(fetchLibraryAudioSource({ element }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.type === "video") {
|
||||
const mediaAsset = mediaMap.get(element.mediaId);
|
||||
if (!mediaAsset) continue;
|
||||
|
||||
if (mediaSupportsAudio({ media: mediaAsset })) {
|
||||
audioMixSources.push(
|
||||
collectMediaAudioSource({ element, mediaAsset }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedLibrarySources = await Promise.all(pendingLibrarySources);
|
||||
for (const source of resolvedLibrarySources) {
|
||||
if (source) audioMixSources.push(source);
|
||||
}
|
||||
|
||||
return audioMixSources;
|
||||
}
|
||||
|
||||
export async function createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
duration,
|
||||
sampleRate = 44100,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaAssets: MediaAsset[];
|
||||
duration: number;
|
||||
sampleRate?: number;
|
||||
}): Promise<AudioBuffer | null> {
|
||||
const audioContext = createAudioContext();
|
||||
|
||||
const audioElements = await collectAudioElements({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
audioContext,
|
||||
});
|
||||
|
||||
if (audioElements.length === 0) return null;
|
||||
|
||||
const outputChannels = 2;
|
||||
const outputLength = Math.ceil(duration * sampleRate);
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
outputChannels,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
);
|
||||
|
||||
for (const element of audioElements) {
|
||||
if (element.muted) continue;
|
||||
|
||||
mixAudioChannels({
|
||||
element,
|
||||
outputBuffer,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
});
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
function mixAudioChannels({
|
||||
element,
|
||||
outputBuffer,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
}: {
|
||||
element: Omit<
|
||||
AudioElement,
|
||||
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
|
||||
>;
|
||||
outputBuffer: AudioBuffer;
|
||||
outputLength: number;
|
||||
sampleRate: number;
|
||||
}): void {
|
||||
const { buffer, startTime, trimStart, duration: elementDuration } = element;
|
||||
|
||||
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
|
||||
const sourceLengthSamples = Math.floor(elementDuration * buffer.sampleRate);
|
||||
const outputStartSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
const resampleRatio = sampleRate / buffer.sampleRate;
|
||||
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
|
||||
|
||||
const outputChannels = 2;
|
||||
for (let channel = 0; channel < outputChannels; channel++) {
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
|
||||
const sourceData = buffer.getChannelData(sourceChannel);
|
||||
|
||||
for (let i = 0; i < resampledLength; i++) {
|
||||
const outputIndex = outputStartSample + i;
|
||||
if (outputIndex >= outputLength) break;
|
||||
|
||||
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
|
||||
if (sourceIndex >= sourceData.length) break;
|
||||
|
||||
outputData[outputIndex] += sourceData[sourceIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isMainTrack } from "@/lib/timeline";
|
||||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
|
@ -35,7 +36,7 @@ export class DeleteElementsCommand extends Command {
|
|||
})
|
||||
.filter(
|
||||
(track) =>
|
||||
track.elements.length > 0 || (track.type === "video" && track.isMain),
|
||||
track.elements.length > 0 || isMainTrack(track),
|
||||
);
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { canHaveAudio } from "@/lib/timeline/element-utils";
|
||||
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class ToggleElementsMutedCommand extends Command {
|
||||
|
|
@ -17,7 +17,7 @@ export class ToggleElementsMutedCommand extends Command {
|
|||
const mutableElements = this.elements.filter(({ trackId, elementId }) => {
|
||||
const track = this.savedState?.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
return element && canHaveAudio(element);
|
||||
return element && canElementHaveAudio(element);
|
||||
});
|
||||
|
||||
if (mutableElements.length === 0) {
|
||||
|
|
@ -27,7 +27,7 @@ export class ToggleElementsMutedCommand extends Command {
|
|||
const shouldMute = mutableElements.some(({ trackId, elementId }) => {
|
||||
const track = this.savedState?.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
return element && canHaveAudio(element) && !element.muted;
|
||||
return element && canElementHaveAudio(element) && !element.muted;
|
||||
});
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
|
|
@ -37,7 +37,7 @@ export class ToggleElementsMutedCommand extends Command {
|
|||
track.id === trackId && element.id === elementId,
|
||||
);
|
||||
return shouldUpdate &&
|
||||
canHaveAudio(element) &&
|
||||
canElementHaveAudio(element) &&
|
||||
element.muted !== shouldMute
|
||||
? { ...element, muted: shouldMute }
|
||||
: element;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { canBeHidden } from "@/lib/timeline/element-utils";
|
||||
import { canElementBeHidden } from "@/lib/timeline/element-utils";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class ToggleElementsVisibilityCommand extends Command {
|
||||
|
|
@ -17,7 +17,7 @@ export class ToggleElementsVisibilityCommand extends Command {
|
|||
const shouldHide = this.elements.some(({ trackId, elementId }) => {
|
||||
const track = this.savedState?.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
return element && canBeHidden(element) && !element.hidden;
|
||||
return element && canElementBeHidden(element) && !element.hidden;
|
||||
});
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
|
|
@ -27,7 +27,7 @@ export class ToggleElementsVisibilityCommand extends Command {
|
|||
track.id === trackId && element.id === elementId,
|
||||
);
|
||||
return shouldUpdate &&
|
||||
canBeHidden(element) &&
|
||||
canElementBeHidden(element) &&
|
||||
element.hidden !== shouldHide
|
||||
? { ...element, hidden: shouldHide }
|
||||
: element;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { getMainTrack } from "@/lib/timeline";
|
||||
|
||||
export class RemoveTrackCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
|
@ -12,6 +13,13 @@ export class RemoveTrackCommand extends Command {
|
|||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
const targetTrack = this.savedState.find(
|
||||
(track) => track.id === this.trackId,
|
||||
);
|
||||
const mainTrack = getMainTrack({ tracks: this.savedState });
|
||||
if (mainTrack?.id === targetTrack?.id) {
|
||||
return;
|
||||
}
|
||||
const updatedTracks = this.savedState.filter(
|
||||
(track) => track.id !== this.trackId,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { canTracktHaveAudio } from "@/lib/timeline";
|
||||
|
||||
export class ToggleTrackMuteCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
|
@ -13,8 +14,17 @@ export class ToggleTrackMuteCommand extends Command {
|
|||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) =>
|
||||
t.id === this.trackId ? { ...t, muted: !t.muted } : t,
|
||||
const targetTrack = this.savedState.find(
|
||||
(track) => track.id === this.trackId,
|
||||
);
|
||||
if (!targetTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTracks = this.savedState.map((track) =>
|
||||
track.id === this.trackId && canTracktHaveAudio(track)
|
||||
? { ...track, muted: !track.muted }
|
||||
: track,
|
||||
);
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
|
|
|||
|
|
@ -2,161 +2,7 @@ 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 { TimelineTrack, type AudioElement } from "@/types/timeline";
|
||||
import { MediaAsset } from "@/types/assets";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAudioElements({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaAssets: MediaAsset[];
|
||||
}): Promise<
|
||||
Omit<
|
||||
AudioElement,
|
||||
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
|
||||
>[]
|
||||
> {
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext || window.webkitAudioContext;
|
||||
const audioContext = new AudioContextConstructor();
|
||||
|
||||
const audioElements: Omit<
|
||||
AudioElement,
|
||||
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
|
||||
>[] = [];
|
||||
const mediaMap = new Map<string, MediaAsset>(
|
||||
mediaAssets.map((m) => [m.id, m]),
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
if (track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
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 || track.muted || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Failed to decode audio:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return audioElements;
|
||||
}
|
||||
|
||||
function mixAudioChannels({
|
||||
element,
|
||||
outputBuffer,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
}: {
|
||||
element: Omit<
|
||||
AudioElement,
|
||||
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
|
||||
>;
|
||||
outputBuffer: AudioBuffer;
|
||||
outputLength: number;
|
||||
sampleRate: number;
|
||||
}): void {
|
||||
const { buffer, startTime, trimStart, duration: elementDuration } = element;
|
||||
|
||||
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
|
||||
const sourceLengthSamples = Math.floor(elementDuration * buffer.sampleRate);
|
||||
const outputStartSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
const resampleRatio = sampleRate / buffer.sampleRate;
|
||||
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
|
||||
|
||||
const outputChannels = 2;
|
||||
for (let channel = 0; channel < outputChannels; channel++) {
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
|
||||
const sourceData = buffer.getChannelData(sourceChannel);
|
||||
|
||||
for (let i = 0; i < resampledLength; i++) {
|
||||
const outputIndex = outputStartSample + i;
|
||||
if (outputIndex >= outputLength) break;
|
||||
|
||||
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
|
||||
if (sourceIndex >= sourceData.length) break;
|
||||
|
||||
outputData[outputIndex] += sourceData[sourceIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
duration,
|
||||
sampleRate = 44100,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaAssets: MediaAsset[];
|
||||
duration: number;
|
||||
sampleRate?: number;
|
||||
}): Promise<AudioBuffer | null> {
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext || window.webkitAudioContext;
|
||||
const audioContext = new AudioContextConstructor();
|
||||
|
||||
const audioElements = await collectAudioElements({ tracks, mediaAssets });
|
||||
|
||||
if (audioElements.length === 0) return null;
|
||||
|
||||
const outputChannels = 2;
|
||||
const outputLength = Math.ceil(duration * sampleRate);
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
outputChannels,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
);
|
||||
|
||||
for (const element of audioElements) {
|
||||
if (element.muted) continue;
|
||||
|
||||
mixAudioChannels({
|
||||
element,
|
||||
outputBuffer,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
});
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
import { createTimelineAudioBuffer } from "@/lib/audio-utils";
|
||||
|
||||
export async function exportProject({
|
||||
format,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
||||
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
||||
import { canHaveAudio } from "@/lib/timeline";
|
||||
import { mediaSupportsAudio } from "@/lib/media-utils";
|
||||
import { collectAudioMixSources } from "@/lib/audio-utils";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
let ffmpeg: FFmpeg | null = null;
|
||||
|
|
@ -66,6 +65,7 @@ export const extractTimelineAudio = async (
|
|||
}
|
||||
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const totalDuration = editor.timeline.getTotalDuration();
|
||||
|
||||
if (totalDuration === 0) {
|
||||
|
|
@ -79,77 +79,12 @@ export const extractTimelineAudio = async (
|
|||
});
|
||||
}
|
||||
|
||||
const audioElements: Array<{
|
||||
file: File;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
trackMuted: boolean;
|
||||
}> = [];
|
||||
const audioMixSources = await collectAudioMixSources({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
for (const track of tracks) {
|
||||
if (track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (!canHaveAudio(element)) continue;
|
||||
|
||||
if (element.type === "audio") {
|
||||
if (element.sourceType === "upload") {
|
||||
const mediaAsset = editor.media
|
||||
.getAssets()
|
||||
.find((asset) => asset.id === element.mediaId);
|
||||
if (!mediaAsset) continue;
|
||||
|
||||
audioElements.push({
|
||||
file: mediaAsset.file,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
trackMuted: track.muted || false,
|
||||
});
|
||||
} else {
|
||||
// library audio - fetch from sourceUrl
|
||||
try {
|
||||
const response = await fetch(element.sourceUrl);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `${element.name}.mp3`, {
|
||||
type: "audio/mpeg",
|
||||
});
|
||||
audioElements.push({
|
||||
file,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
trackMuted: track.muted || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch library audio:", error);
|
||||
}
|
||||
}
|
||||
} else if (element.type === "video") {
|
||||
const mediaAsset = editor.media
|
||||
.getAssets()
|
||||
.find((asset) => asset.id === element.mediaId);
|
||||
if (!mediaAsset) continue;
|
||||
|
||||
if (mediaSupportsAudio({ media: mediaAsset })) {
|
||||
audioElements.push({
|
||||
file: mediaAsset.file,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
trackMuted: track.muted || false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioElements.length === 0) {
|
||||
if (audioMixSources.length === 0) {
|
||||
// Return silent audio if no audio elements
|
||||
const silentDuration = Math.max(1, totalDuration); // At least 1 second
|
||||
try {
|
||||
|
|
@ -166,8 +101,8 @@ export const extractTimelineAudio = async (
|
|||
const filterInputs: string[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < audioElements.length; i++) {
|
||||
const element = audioElements[i];
|
||||
for (let i = 0; i < audioMixSources.length; i++) {
|
||||
const element = audioMixSources[i];
|
||||
const inputName = `input_${i}.${element.file.name.split(".").pop()}`;
|
||||
inputFiles.push(inputName);
|
||||
|
||||
|
|
@ -193,9 +128,9 @@ export const extractTimelineAudio = async (
|
|||
}
|
||||
|
||||
const mixFilter =
|
||||
audioElements.length === 1
|
||||
audioMixSources.length === 1
|
||||
? `[audio_0]aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`
|
||||
: `${filterInputs.map((_, i) => `[audio_${i}]`).join("")}amix=inputs=${audioElements.length}:duration=longest:dropout_transition=2,aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`;
|
||||
: `${filterInputs.map((_, i) => `[audio_${i}]`).join("")}amix=inputs=${audioMixSources.length}:duration=longest:dropout_transition=2,aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`;
|
||||
|
||||
const complexFilter = [...filterInputs, mixFilter].join(";");
|
||||
const outputName = "timeline_audio.wav";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export abstract class StorageMigration {
|
||||
abstract from: number;
|
||||
abstract to: number;
|
||||
abstract run(): Promise<void>;
|
||||
}
|
||||
|
|
@ -1,72 +1,9 @@
|
|||
import { TProject } from "@/types/project";
|
||||
import { migrateV0ToV1, needsV0ToV1Migration } from "./v0-to-v1";
|
||||
export { StorageMigration } from "./base";
|
||||
export { StorageVersionManager } from "./version-manager";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
import { V0toV1Migration } from "./v0-to-v1";
|
||||
import { V1toV2Migration } from "./v1-to-v2";
|
||||
|
||||
export const CURRENT_VERSION = 1;
|
||||
export const CURRENT_STORAGE_VERSION = 2;
|
||||
|
||||
type Migration = {
|
||||
from: number;
|
||||
to: number;
|
||||
migrate: ({ project }: { project: TProject }) => TProject;
|
||||
needsMigration: ({ project }: { project: TProject }) => boolean;
|
||||
};
|
||||
|
||||
const migrations: Migration[] = [
|
||||
{
|
||||
from: 0,
|
||||
to: 1,
|
||||
migrate: migrateV0ToV1,
|
||||
needsMigration: needsV0ToV1Migration,
|
||||
},
|
||||
];
|
||||
|
||||
export interface MigrationResult {
|
||||
project: TProject;
|
||||
migrated: boolean;
|
||||
fromVersion?: number;
|
||||
toVersion?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs all necessary migrations on a project to bring it to the current version
|
||||
*/
|
||||
export function runMigrations({
|
||||
project,
|
||||
}: {
|
||||
project: TProject;
|
||||
}): MigrationResult {
|
||||
let currentProject = { ...project };
|
||||
let currentVersion = project.version ?? inferVersion({ project });
|
||||
let migrated = false;
|
||||
const originalVersion = currentVersion;
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (currentVersion === migration.from) {
|
||||
if (migration.needsMigration({ project: currentProject })) {
|
||||
currentProject = migration.migrate({ project: currentProject });
|
||||
currentVersion = migration.to;
|
||||
migrated = true;
|
||||
} else {
|
||||
currentProject.version = migration.to;
|
||||
currentVersion = migration.to;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
project: currentProject,
|
||||
migrated,
|
||||
fromVersion: migrated ? originalVersion : undefined,
|
||||
toVersion: migrated ? currentVersion : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the version of a legacy project that has no version field
|
||||
*/
|
||||
function inferVersion({ project }: { project: TProject }): number {
|
||||
if (!project.scenes || project.scenes.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return CURRENT_VERSION;
|
||||
}
|
||||
export const migrations = [new V0toV1Migration(), new V1toV2Migration()];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import { IndexedDBAdapter } from "@/lib/storage/indexeddb-adapter";
|
||||
import { StorageMigration } from "./base";
|
||||
import { StorageVersionManager } from "./version-manager";
|
||||
|
||||
export interface StorageMigrationResult {
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
migrated: boolean;
|
||||
}
|
||||
|
||||
export type StorageMigrationCallbacks = {
|
||||
onMigrationStart?: ({
|
||||
fromVersion,
|
||||
toVersion,
|
||||
}: {
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
}) => void;
|
||||
onMigrationComplete?: ({
|
||||
fromVersion,
|
||||
toVersion,
|
||||
}: {
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
type ProjectRecord = Record<string, unknown>;
|
||||
|
||||
export async function runStorageMigrations({
|
||||
migrations,
|
||||
versionManager = new StorageVersionManager(),
|
||||
callbacks,
|
||||
}: {
|
||||
migrations: StorageMigration[];
|
||||
versionManager?: StorageVersionManager;
|
||||
callbacks?: StorageMigrationCallbacks;
|
||||
}): Promise<StorageMigrationResult> {
|
||||
const versionRecord = await versionManager.getVersionRecord();
|
||||
const inferredVersion = versionRecord
|
||||
? null
|
||||
: await inferStorageVersionFromProjects();
|
||||
const fromVersion =
|
||||
versionRecord?.inProgress?.from ??
|
||||
versionRecord?.version ??
|
||||
inferredVersion ??
|
||||
0;
|
||||
|
||||
if (!versionRecord) {
|
||||
await versionManager.setVersion({ version: fromVersion });
|
||||
}
|
||||
|
||||
const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from);
|
||||
let currentVersion = fromVersion;
|
||||
|
||||
for (const migration of orderedMigrations) {
|
||||
if (migration.from !== currentVersion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await versionManager.setInProgress({
|
||||
from: migration.from,
|
||||
to: migration.to,
|
||||
});
|
||||
callbacks?.onMigrationStart?.({
|
||||
fromVersion: migration.from,
|
||||
toVersion: migration.to,
|
||||
});
|
||||
await migration.run();
|
||||
currentVersion = migration.to;
|
||||
await versionManager.setVersion({ version: currentVersion });
|
||||
await versionManager.clearInProgress();
|
||||
callbacks?.onMigrationComplete?.({
|
||||
fromVersion: migration.from,
|
||||
toVersion: migration.to,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
fromVersion,
|
||||
toVersion: currentVersion,
|
||||
migrated: currentVersion !== fromVersion,
|
||||
};
|
||||
}
|
||||
|
||||
async function inferStorageVersionFromProjects(): Promise<number> {
|
||||
const projectsAdapter = new IndexedDBAdapter<unknown>(
|
||||
"video-editor-projects",
|
||||
"projects",
|
||||
1,
|
||||
);
|
||||
const projects = await projectsAdapter.getAll();
|
||||
|
||||
if (projects.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let lowestVersion = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const project of projects) {
|
||||
const projectVersion = checkProjectVersion({ project });
|
||||
if (projectVersion < lowestVersion) {
|
||||
lowestVersion = projectVersion;
|
||||
}
|
||||
}
|
||||
|
||||
if (lowestVersion === Number.POSITIVE_INFINITY) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return lowestVersion;
|
||||
}
|
||||
|
||||
function checkProjectVersion({ project }: { project: unknown }): number {
|
||||
if (!isRecord(project)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const versionValue = project.version;
|
||||
if (typeof versionValue === "number") {
|
||||
return versionValue;
|
||||
}
|
||||
|
||||
const scenesValue = project.scenes;
|
||||
if (Array.isArray(scenesValue) && scenesValue.length > 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is ProjectRecord {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
@ -1,34 +1,93 @@
|
|||
import { TProject } from "@/types/project";
|
||||
import { buildDefaultScene } from "../scene-utils";
|
||||
import { IndexedDBAdapter } from "@/lib/storage/indexeddb-adapter";
|
||||
import { SerializedScene } from "@/lib/storage/types";
|
||||
import { buildDefaultScene } from "@/lib/scene-utils";
|
||||
import { TScene } from "@/types/timeline";
|
||||
import { StorageMigration } from "./base";
|
||||
|
||||
/**
|
||||
* Migration v0 → v1: Add scenes support to legacy projects
|
||||
*
|
||||
* Legacy projects (v0) had no scenes array or an empty scenes array.
|
||||
* This migration creates a main scene for such projects.
|
||||
*/
|
||||
export function migrateV0ToV1({ project }: { project: TProject }): TProject {
|
||||
const mainScene = buildDefaultScene({ isMain: true, name: "Main scene" });
|
||||
type ProjectRecord = Record<string, unknown>;
|
||||
|
||||
export class V0toV1Migration extends StorageMigration {
|
||||
from = 0;
|
||||
to = 1;
|
||||
|
||||
async run(): Promise<void> {
|
||||
const projectsAdapter = new IndexedDBAdapter<unknown>(
|
||||
"video-editor-projects",
|
||||
"projects",
|
||||
1,
|
||||
);
|
||||
const projects = await projectsAdapter.getAll();
|
||||
|
||||
for (const project of projects) {
|
||||
if (!isRecord(project)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scenesValue = project.scenes;
|
||||
if (Array.isArray(scenesValue) && scenesValue.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mainScene = buildDefaultScene({ isMain: true, name: "Main scene" });
|
||||
const serializedScene = serializeScene({ scene: mainScene });
|
||||
const updatedProject: ProjectRecord = {
|
||||
...project,
|
||||
scenes: [serializedScene],
|
||||
currentSceneId: mainScene.id,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
if (isRecord(project.metadata)) {
|
||||
updatedProject.metadata = {
|
||||
...project.metadata,
|
||||
updatedAt,
|
||||
};
|
||||
} else {
|
||||
updatedProject.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
const projectId = getProjectId({ project: updatedProject });
|
||||
if (!projectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await projectsAdapter.set(projectId, updatedProject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeScene({ scene }: { scene: TScene }): SerializedScene {
|
||||
return {
|
||||
...project,
|
||||
scenes: [mainScene],
|
||||
currentSceneId: mainScene.id,
|
||||
version: 1,
|
||||
metadata: {
|
||||
...project.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
id: scene.id,
|
||||
name: scene.name,
|
||||
isMain: scene.isMain,
|
||||
tracks: scene.tracks,
|
||||
bookmarks: scene.bookmarks,
|
||||
createdAt: scene.createdAt.toISOString(),
|
||||
updatedAt: scene.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if project needs v0→v1 migration
|
||||
*/
|
||||
export function needsV0ToV1Migration({
|
||||
project,
|
||||
}: {
|
||||
project: TProject;
|
||||
}): boolean {
|
||||
return !project.scenes || project.scenes.length === 0;
|
||||
function getProjectId({ project }: { project: ProjectRecord }): string | null {
|
||||
const idValue = project.id;
|
||||
if (typeof idValue === "string" && idValue.length > 0) {
|
||||
return idValue;
|
||||
}
|
||||
|
||||
const metadataValue = project.metadata;
|
||||
if (!isRecord(metadataValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadataId = metadataValue.id;
|
||||
if (typeof metadataId === "string" && metadataId.length > 0) {
|
||||
return metadataId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is ProjectRecord {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,339 @@
|
|||
import {
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
DEFAULT_COLOR,
|
||||
DEFAULT_FPS,
|
||||
} from "@/constants/project-constants";
|
||||
import { IndexedDBAdapter } from "@/lib/storage/indexeddb-adapter";
|
||||
import { StorageMigration } from "./base";
|
||||
|
||||
type ProjectRecord = Record<string, unknown>;
|
||||
|
||||
export class V1toV2Migration extends StorageMigration {
|
||||
from = 1;
|
||||
to = 2;
|
||||
|
||||
async run(): Promise<void> {
|
||||
const projectsAdapter = new IndexedDBAdapter<unknown>(
|
||||
"video-editor-projects",
|
||||
"projects",
|
||||
1,
|
||||
);
|
||||
const projects = await projectsAdapter.getAll();
|
||||
|
||||
for (const project of projects) {
|
||||
if (!isRecord(project)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectId = getProjectId({ project });
|
||||
if (!projectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isV2Project({ project })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const migratedProject = migrateProject({ project, projectId });
|
||||
await projectsAdapter.set(projectId, migratedProject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrateProject({
|
||||
project,
|
||||
projectId,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
projectId: string;
|
||||
}): ProjectRecord {
|
||||
const createdAt = normalizeDateString({ value: project.createdAt });
|
||||
const updatedAt = normalizeDateString({ value: project.updatedAt });
|
||||
const metadataValue = project.metadata;
|
||||
|
||||
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: projectId,
|
||||
name: getStringValue({ value: project.name, fallback: "" }),
|
||||
thumbnail: getStringValue({ value: project.thumbnail }),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
|
||||
const scenesValue = project.scenes;
|
||||
const scenes = Array.isArray(scenesValue) ? scenesValue : [];
|
||||
const legacyBookmarks = Array.isArray(project.bookmarks)
|
||||
? project.bookmarks
|
||||
: null;
|
||||
const normalizedScenes = applyLegacyBookmarks({
|
||||
scenes,
|
||||
legacyBookmarks,
|
||||
});
|
||||
|
||||
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: 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,
|
||||
scenes: normalizedScenes,
|
||||
});
|
||||
|
||||
return {
|
||||
...project,
|
||||
metadata,
|
||||
scenes: normalizedScenes,
|
||||
currentSceneId,
|
||||
settings,
|
||||
version: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getProjectId({ project }: { project: ProjectRecord }): string | null {
|
||||
const idValue = project.id;
|
||||
if (typeof idValue === "string" && idValue.length > 0) {
|
||||
return idValue;
|
||||
}
|
||||
|
||||
const metadataValue = project.metadata;
|
||||
if (!isRecord(metadataValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadataId = metadataValue.id;
|
||||
if (typeof metadataId === "string" && metadataId.length > 0) {
|
||||
return metadataId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCurrentSceneId({
|
||||
value,
|
||||
scenes,
|
||||
}: {
|
||||
value: unknown;
|
||||
scenes: unknown[];
|
||||
}): string {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const mainSceneId = findMainSceneId({ scenes });
|
||||
if (mainSceneId) {
|
||||
return mainSceneId;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function findMainSceneId({ scenes }: { scenes: unknown[] }): string | null {
|
||||
for (const scene of scenes) {
|
||||
if (!isRecord(scene)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scene.isMain === true && typeof scene.id === "string") {
|
||||
return scene.id;
|
||||
}
|
||||
}
|
||||
|
||||
for (const scene of scenes) {
|
||||
if (!isRecord(scene)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof scene.id === "string") {
|
||||
return scene.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyLegacyBookmarks({
|
||||
scenes,
|
||||
legacyBookmarks,
|
||||
}: {
|
||||
scenes: unknown[];
|
||||
legacyBookmarks: unknown[] | null;
|
||||
}): unknown[] {
|
||||
if (!legacyBookmarks || legacyBookmarks.length === 0) {
|
||||
return scenes;
|
||||
}
|
||||
|
||||
const mainSceneId = findMainSceneId({ scenes });
|
||||
|
||||
return scenes.map((scene) => {
|
||||
if (!isRecord(scene)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
if (mainSceneId && scene.id !== mainSceneId) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
if (Array.isArray(scene.bookmarks) && scene.bookmarks.length > 0) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
return {
|
||||
...scene,
|
||||
bookmarks: legacyBookmarks,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getBackgroundValue({
|
||||
value,
|
||||
backgroundType,
|
||||
backgroundColor,
|
||||
blurIntensity,
|
||||
}: {
|
||||
value: unknown;
|
||||
backgroundType?: unknown;
|
||||
backgroundColor?: unknown;
|
||||
blurIntensity?: unknown;
|
||||
}): {
|
||||
type: "color" | "blur";
|
||||
color?: string;
|
||||
blurIntensity?: number;
|
||||
} {
|
||||
if (isRecord(value)) {
|
||||
const typeValue = value.type;
|
||||
if (typeValue === "blur") {
|
||||
return {
|
||||
type: "blur",
|
||||
blurIntensity: getNumberValue({
|
||||
value: value.blurIntensity,
|
||||
fallback: DEFAULT_BLUR_INTENSITY,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "color",
|
||||
color: getStringValue({ value: value.color, fallback: DEFAULT_COLOR }),
|
||||
};
|
||||
}
|
||||
|
||||
if (backgroundType === "blur") {
|
||||
return {
|
||||
type: "blur",
|
||||
blurIntensity: getNumberValue({
|
||||
value: blurIntensity,
|
||||
fallback: DEFAULT_BLUR_INTENSITY,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "color",
|
||||
color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }),
|
||||
};
|
||||
}
|
||||
|
||||
function getCanvasSizeValue({
|
||||
value,
|
||||
fallback,
|
||||
}: {
|
||||
value: unknown;
|
||||
fallback: { width: number; height: number };
|
||||
}): { width: number; height: number } {
|
||||
if (isRecord(value)) {
|
||||
const width = getNumberValue({
|
||||
value: value.width,
|
||||
fallback: fallback.width,
|
||||
});
|
||||
const height = getNumberValue({
|
||||
value: value.height,
|
||||
fallback: fallback.height,
|
||||
});
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getNumberValue({
|
||||
value,
|
||||
fallback,
|
||||
}: {
|
||||
value: unknown;
|
||||
fallback: number;
|
||||
}): number {
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
function getStringValue({
|
||||
value,
|
||||
fallback,
|
||||
}: {
|
||||
value: unknown;
|
||||
fallback?: string;
|
||||
}): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeDateString({ value }: { value: unknown }): string {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function isV2Project({ project }: { project: ProjectRecord }): boolean {
|
||||
const versionValue = project.version;
|
||||
if (typeof versionValue === "number" && versionValue >= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isRecord(project.metadata) && isRecord(project.settings);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is ProjectRecord {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { IndexedDBAdapter } from "@/lib/storage/indexeddb-adapter";
|
||||
|
||||
type StorageVersionRecord = {
|
||||
version: number;
|
||||
inProgress?: {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_DB_NAME = "video-editor-meta";
|
||||
const DEFAULT_STORE_NAME = "storage-version";
|
||||
const DEFAULT_DB_VERSION = 1;
|
||||
const STORAGE_VERSION_KEY = "storage-version";
|
||||
|
||||
export class StorageVersionManager {
|
||||
private adapter: IndexedDBAdapter<StorageVersionRecord>;
|
||||
|
||||
constructor({
|
||||
dbName = DEFAULT_DB_NAME,
|
||||
storeName = DEFAULT_STORE_NAME,
|
||||
version = DEFAULT_DB_VERSION,
|
||||
}: {
|
||||
dbName?: string;
|
||||
storeName?: string;
|
||||
version?: number;
|
||||
} = {}) {
|
||||
this.adapter = new IndexedDBAdapter<StorageVersionRecord>(
|
||||
dbName,
|
||||
storeName,
|
||||
version,
|
||||
);
|
||||
}
|
||||
|
||||
async getVersion(): Promise<number> {
|
||||
const record = await this.adapter.get(STORAGE_VERSION_KEY);
|
||||
return record?.version ?? 0;
|
||||
}
|
||||
|
||||
async getVersionRecord(): Promise<StorageVersionRecord | null> {
|
||||
return this.adapter.get(STORAGE_VERSION_KEY);
|
||||
}
|
||||
|
||||
async setVersion({ version }: { version: number }): Promise<void> {
|
||||
const record = await this.getVersionRecord();
|
||||
const inProgress = record?.inProgress;
|
||||
await this.adapter.set(STORAGE_VERSION_KEY, {
|
||||
version,
|
||||
...(inProgress ? { inProgress } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async setInProgress({
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
from: number;
|
||||
to: number;
|
||||
}): Promise<void> {
|
||||
const record = await this.getVersionRecord();
|
||||
const version = record?.version ?? 0;
|
||||
await this.adapter.set(STORAGE_VERSION_KEY, {
|
||||
version,
|
||||
inProgress: { from, to },
|
||||
});
|
||||
}
|
||||
|
||||
async clearInProgress(): Promise<void> {
|
||||
const record = await this.getVersionRecord();
|
||||
const version = record?.version ?? 0;
|
||||
await this.adapter.set(STORAGE_VERSION_KEY, { version });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { TScene } from "@/types/timeline";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
||||
|
||||
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
||||
return scenes.find((scene) => scene.isMain) || null;
|
||||
|
|
@ -8,15 +9,7 @@ export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
|||
export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] {
|
||||
const hasMain = scenes.some((scene) => scene.isMain);
|
||||
if (!hasMain) {
|
||||
const mainScene: TScene = {
|
||||
id: generateUUID(),
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
tracks: [],
|
||||
bookmarks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
|
||||
return [mainScene, ...scenes];
|
||||
}
|
||||
return scenes;
|
||||
|
|
@ -29,11 +22,12 @@ export function buildDefaultScene({
|
|||
name: string;
|
||||
isMain: boolean;
|
||||
}): TScene {
|
||||
const tracks = ensureMainTrack({ tracks: [] });
|
||||
return {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
isMain,
|
||||
tracks: [],
|
||||
tracks,
|
||||
bookmarks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ import type {
|
|||
SerializedScene,
|
||||
} from "./types";
|
||||
import { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds";
|
||||
import { migrations, runStorageMigrations } from "@/lib/migrations";
|
||||
|
||||
class StorageService {
|
||||
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
|
||||
private savedSoundsAdapter: IndexedDBAdapter<SavedSoundsData>;
|
||||
private config: StorageConfig;
|
||||
private migrationsPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.config = {
|
||||
|
|
@ -36,6 +38,18 @@ class StorageService {
|
|||
);
|
||||
}
|
||||
|
||||
private async ensureMigrations(): Promise<void> {
|
||||
if (this.migrationsPromise) {
|
||||
await this.migrationsPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.migrationsPromise = runStorageMigrations({ migrations }).then(
|
||||
() => undefined,
|
||||
);
|
||||
await this.migrationsPromise;
|
||||
}
|
||||
|
||||
private getProjectMediaAdapters({ projectId }: { projectId: string }) {
|
||||
const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>(
|
||||
`${this.config.mediaDb}-${projectId}`,
|
||||
|
|
@ -81,10 +95,19 @@ class StorageService {
|
|||
}: {
|
||||
id: string;
|
||||
}): Promise<{ project: TProject } | null> {
|
||||
await this.ensureMigrations();
|
||||
const serializedProject = await this.projectsAdapter.get(id);
|
||||
|
||||
if (!serializedProject) return null;
|
||||
|
||||
console.log(
|
||||
"[storage] loadProject scenes",
|
||||
JSON.stringify({
|
||||
projectId: id,
|
||||
scenes: serializedProject.scenes ?? [],
|
||||
}),
|
||||
);
|
||||
|
||||
const scenes =
|
||||
serializedProject.scenes?.map((scene) => ({
|
||||
id: scene.id,
|
||||
|
|
@ -134,6 +157,7 @@ class StorageService {
|
|||
}
|
||||
|
||||
async loadAllProjectsMetadata(): Promise<TProjectMetadata[]> {
|
||||
await this.ensureMigrations();
|
||||
const serializedProjects = await this.projectsAdapter.getAll();
|
||||
|
||||
const metadata = serializedProjects.map((serializedProject) => ({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { TimelineTrack, ElementType } from "@/types/timeline";
|
|||
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
|
||||
import { wouldElementOverlap } from "./element-utils";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
|
||||
import { isMainTrack } from "./track-utils";
|
||||
|
||||
function getTrackAtY({
|
||||
mouseY,
|
||||
|
|
@ -47,7 +48,7 @@ function isCompatible({
|
|||
}
|
||||
|
||||
function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
|
||||
return tracks.findIndex((t) => t.type === "video" && t.isMain);
|
||||
return tracks.findIndex((track) => isMainTrack(track));
|
||||
}
|
||||
|
||||
function findInsertIndex({
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ import {
|
|||
UploadAudioElement,
|
||||
} from "@/types/timeline";
|
||||
|
||||
export function canHaveAudio(
|
||||
export function canElementHaveAudio(
|
||||
element: TimelineElement,
|
||||
): element is AudioElement | VideoElement {
|
||||
return element.type === "audio" || element.type === "video";
|
||||
}
|
||||
|
||||
export function canBeHidden(
|
||||
export function canElementBeHidden(
|
||||
element: TimelineElement,
|
||||
): element is VideoElement | ImageElement | TextElement | StickerElement {
|
||||
return element.type !== "audio";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import type { TrackType, TimelineTrack, ElementType } from "@/types/timeline";
|
||||
import type {
|
||||
TrackType,
|
||||
TimelineTrack,
|
||||
ElementType,
|
||||
VideoTrack,
|
||||
AudioTrack,
|
||||
StickerTrack,
|
||||
TextTrack,
|
||||
} from "@/types/timeline";
|
||||
import {
|
||||
TRACK_COLORS,
|
||||
TRACK_HEIGHTS,
|
||||
|
|
@ -6,6 +14,18 @@ import {
|
|||
} from "@/constants/timeline-constants";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export function canTracktHaveAudio(
|
||||
track: TimelineTrack,
|
||||
): track is VideoTrack | AudioTrack {
|
||||
return track.type === "audio" || track.type === "video";
|
||||
}
|
||||
|
||||
export function canTrackBeHidden(
|
||||
track: TimelineTrack,
|
||||
): track is VideoTrack | TextTrack | StickerTrack {
|
||||
return track.type !== "audio";
|
||||
}
|
||||
|
||||
export function getTrackColor({ type }: { type: TrackType }) {
|
||||
return TRACK_COLORS[type];
|
||||
}
|
||||
|
|
@ -47,12 +67,16 @@ export function getTotalTracksHeight({
|
|||
return tracksHeight + gapsHeight;
|
||||
}
|
||||
|
||||
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
|
||||
return track.type === "video" && track.isMain === true;
|
||||
}
|
||||
|
||||
export function getMainTrack({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): TimelineTrack | null {
|
||||
return tracks.find((track) => track.type === "video" && track.isMain) ?? null;
|
||||
return tracks.find((track) => isMainTrack(track)) ?? null;
|
||||
}
|
||||
|
||||
export function ensureMainTrack({
|
||||
|
|
@ -60,9 +84,7 @@ export function ensureMainTrack({
|
|||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): TimelineTrack[] {
|
||||
const hasMainTrack = tracks.some(
|
||||
(track) => track.type === "video" && track.isMain,
|
||||
);
|
||||
const hasMainTrack = tracks.some((track) => isMainTrack(track));
|
||||
|
||||
if (!hasMainTrack) {
|
||||
const mainTrack: TimelineTrack = {
|
||||
|
|
|
|||
|
|
@ -9,6 +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";
|
||||
|
||||
export type BuildSceneParams = {
|
||||
canvasSize: TCanvasSize;
|
||||
|
|
@ -33,7 +34,7 @@ export function buildScene(params: BuildSceneParams) {
|
|||
const elements = tracks
|
||||
.slice()
|
||||
.reverse()
|
||||
.filter((track) => !track.muted)
|
||||
.filter((track) => !(canTracktHaveAudio(track) && track.muted))
|
||||
.flatMap((track): TimelineElement[] => track.elements);
|
||||
|
||||
const contentNodes = [];
|
||||
|
|
|
|||
|
|
@ -38,11 +38,9 @@ export const useEditorStore = create<EditorState>()(
|
|||
},
|
||||
|
||||
initializeApp: async () => {
|
||||
console.log("Initializing video editor...");
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
console.log("Video editor ready");
|
||||
},
|
||||
|
||||
setLayoutGuide: (settings) => {
|
||||
|
|
|
|||
|
|
@ -13,28 +13,32 @@ export type TrackType = "video" | "text" | "audio" | "sticker";
|
|||
interface BaseTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoTrack extends BaseTrack {
|
||||
type: "video";
|
||||
elements: (VideoElement | ImageElement)[];
|
||||
isMain: boolean;
|
||||
muted: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface TextTrack extends BaseTrack {
|
||||
type: "text";
|
||||
elements: TextElement[];
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface AudioTrack extends BaseTrack {
|
||||
type: "audio";
|
||||
elements: AudioElement[];
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface StickerTrack extends BaseTrack {
|
||||
type: "sticker";
|
||||
elements: StickerElement[];
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack;
|
||||
|
|
@ -48,15 +52,6 @@ export interface Transform {
|
|||
rotate: number;
|
||||
}
|
||||
|
||||
interface BaseTimelineElement {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
}
|
||||
|
||||
interface BaseAudioElement extends BaseTimelineElement {
|
||||
type: "audio";
|
||||
volume: number;
|
||||
|
|
@ -74,6 +69,15 @@ export interface LibraryAudioElement extends BaseAudioElement {
|
|||
sourceUrl: string;
|
||||
}
|
||||
|
||||
interface BaseTimelineElement {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
}
|
||||
|
||||
export type AudioElement = UploadAudioElement | LibraryAudioElement;
|
||||
|
||||
export interface VideoElement extends BaseTimelineElement {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -178,16 +178,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
|
|||
activeProject,
|
||||
sceneId
|
||||
);
|
||||
console.log(cached.timelineHash === currentHash);
|
||||
if (cached.timelineHash !== currentHash) {
|
||||
// Cache is stale, remove it
|
||||
console.log(
|
||||
"Cache miss - hash mismatch:",
|
||||
JSON.stringify({
|
||||
cachedHash: cached.timelineHash.slice(0, 100),
|
||||
currentHash: currentHash.slice(0, 100),
|
||||
})
|
||||
);
|
||||
frameCacheRef.current.delete(frameKey);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue