This commit is contained in:
Maze Winther 2026-01-16 18:07:28 +01:00
parent 0dddf4e13d
commit 0934db2aba
60 changed files with 1900 additions and 1202 deletions

6
.cursor/debug.log Normal file
View File

@ -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"}

View File

@ -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 {

View File

@ -1,3 +1,4 @@
{
"/editor/[project_id]/page": "app/editor/[project_id]/page.js",
"/projects/page": "app/projects/page.js"
}

View File

@ -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

View File

@ -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) {

View File

@ -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>

View File

@ -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",

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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` }}
/>
);
}

View File

@ -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)

View File

@ -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) {

View File

@ -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;
}

View File

@ -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`;
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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,

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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,

View File

@ -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;

View File

@ -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(

View File

@ -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

View File

@ -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]);

View File

@ -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]);
}

View File

@ -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",
() => {

View File

@ -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,
]);
}

View File

@ -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,
};
}

View File

@ -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",

View File

@ -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];
}
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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,
);

View File

@ -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);

View File

@ -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,

View File

@ -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";

View File

@ -0,0 +1,5 @@
export abstract class StorageMigration {
abstract from: number;
abstract to: number;
abstract run(): Promise<void>;
}

View File

@ -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()];

View File

@ -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;
}

View File

@ -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 v0v1 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;
}

View File

@ -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;
}

View File

@ -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 });
}
}

View File

@ -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(),

View File

@ -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) => ({

View File

@ -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({

View File

@ -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";

View File

@ -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 = {

View File

@ -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 = [];

View File

@ -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) => {

View File

@ -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

View File

@ -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;
}