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
@@ -55,7 +55,7 @@ export function TabBar() {
return (
<div
className={cn(
"z-[100] flex cursor-pointer flex-col items-center gap-0.5",
"flex cursor-pointer flex-col items-center gap-0.5",
activeTab === tabKey
? "text-primary !opacity-100"
: "text-muted-foreground",
@@ -73,7 +73,7 @@ export function TabBar() {
variant="sidebar"
sideOffset={8}
>
<div className="dark:text-base-gray-950 text-sm font-medium leading-none text-black dark:text-white">
<div className="text-foreground text-sm font-medium leading-none">
{tab.label}
</div>
</TooltipContent>
@@ -99,7 +99,7 @@ function FadeOverlay({
return (
<div
className={cn(
"pointer-events-none absolute left-0 right-0 z-[101] h-6 transition-opacity duration-200",
"pointer-events-none absolute left-0 right-0 h-6 transition-opacity duration-200",
direction === "top" && show
? "from-panel top-0 bg-gradient-to-b to-transparent"
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
@@ -95,8 +95,6 @@ export function Captions() {
const { text, segments } = await transcriptionResponse.json();
console.log("Transcription completed:", { text, segments });
const shortCaptions: Array<{
text: string;
startTime: number;
@@ -132,7 +130,10 @@ export function Captions() {
});
});
const captionTrackId = editor.timeline.addTrack({ type: "text", index: 0 });
const captionTrackId = editor.timeline.addTrack({
type: "text",
index: 0,
});
shortCaptions.forEach((caption, index) => {
editor.timeline.addElementToTrack({
@@ -148,14 +149,10 @@ export function Captions() {
} as TextElement,
});
});
console.log(
`${shortCaptions.length} short-form caption chunks added to timeline!`
);
} catch (error) {
console.error("Transcription failed:", error);
setError(
error instanceof Error ? error.message : "An unexpected error occurred"
error instanceof Error ? error.message : "An unexpected error occurred",
);
} finally {
setIsProcessing(false);
@@ -164,7 +161,10 @@ export function Captions() {
};
return (
<BaseView ref={containerRef} className="flex flex-col justify-between h-full">
<BaseView
ref={containerRef}
className="flex h-full flex-col justify-between"
>
<PropertyGroup title="Language">
<LanguageSelect
selectedCountry={selectedCountry}
@@ -176,8 +176,8 @@ export function Captions() {
<div className="flex flex-col gap-4">
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
<div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
<p className="text-destructive text-sm">{error}</p>
</div>
)}
@@ -192,7 +192,7 @@ export function Captions() {
}}
disabled={isProcessing}
>
{isProcessing && <Loader2 className="mr-1 size-4 animate-spin" />}
{isProcessing && <Loader2 className="mr-1 animate-spin" />}
{isProcessing ? processingStep : "Generate transcript"}
</Button>
@@ -243,7 +243,7 @@ export function Captions() {
</div>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
<strong>True zero-knowledge privacy:</strong> Encryption keys
are generated randomly in your browser and never stored
anywhere. It's cryptographically impossible for us, our cloud
@@ -231,9 +231,9 @@ export function MediaView() {
className="w-full"
>
{isProcessing ? (
<Loader2 className="size-4 animate-spin" />
<Loader2 className="animate-spin" />
) : (
<CloudUpload className="size-4" />
<CloudUpload />
)}
<span>Upload</span>
</Button>
@@ -239,7 +239,7 @@ function SoundEffectsView() {
size="icon"
className={cn(showCommercialOnly && "text-primary")}
>
<ListFilter className="w-4 h-4" />
<ListFilter />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
@@ -521,9 +521,9 @@ function AudioItem({
<div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" />
{isPlaying ? (
<PauseIcon className="w-5 h-5" />
<PauseIcon className="size-5" />
) : (
<PlayIcon className="w-5 h-5" />
<PlayIcon className="size-5" />
)}
</div>
@@ -542,7 +542,7 @@ function AudioItem({
onClick={handleAddToTimeline}
title="Add to timeline"
>
<PlusIcon className="w-4 h-4" />
<PlusIcon />
</Button>
<Button
variant="text"
@@ -555,7 +555,7 @@ function AudioItem({
onClick={handleSaveClick}
title={isSaved ? "Remove from saved" : "Save sound"}
>
<HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} />
<HeartIcon className={`${isSaved ? "fill-current" : ""}`} />
</Button>
</div>
</div>
@@ -16,6 +16,13 @@ export function MigrationDialog() {
if (!migrationState.isMigrating) return null;
const title = migrationState.projectName
? "Updating project"
: "Updating projects";
const description = migrationState.projectName
? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}`
: `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`;
return (
<Dialog open={true}>
<DialogContent
@@ -24,11 +31,8 @@ export function MigrationDialog() {
onEscapeKeyDown={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>Updating project</DialogTitle>
<DialogDescription>
Upgrading "{migrationState.projectName}" from v
{migrationState.fromVersion} to v{migrationState.toVersion}
</DialogDescription>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-4">
+22 -13
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>
@@ -0,0 +1,85 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { useEditor } from "@/hooks/use-editor";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { Bookmark } from "lucide-react";
interface TimelineBookmarksRowProps {
zoomLevel: number;
dynamicTimelineWidth: number;
bookmarksScrollRef: React.RefObject<HTMLDivElement>;
handleWheel: (e: React.WheelEvent) => void;
handleTimelineContentClick: (e: React.MouseEvent) => void;
handleRulerMouseDown: (e: React.MouseEvent) => void;
}
export function TimelineBookmarksRow({
zoomLevel,
dynamicTimelineWidth,
bookmarksScrollRef,
handleWheel,
handleTimelineContentClick,
handleRulerMouseDown,
}: TimelineBookmarksRowProps) {
const editor = useEditor();
const activeScene = editor.scenes.getActiveScene();
return (
<div
className="relative mt-0.5 h-4 flex-1 overflow-hidden"
onWheel={handleWheel}
onClick={handleTimelineContentClick}
data-bookmarks-area
>
<ScrollArea className="scrollbar-hidden w-full" ref={bookmarksScrollRef}>
<div
className="relative h-4 cursor-default select-none"
style={{
width: `${dynamicTimelineWidth}px`,
}}
onMouseDown={handleRulerMouseDown}
>
{activeScene.bookmarks.map((time: number, index: number) => (
<TimelineBookmark
key={`bookmark-row-${index}`}
time={time}
zoomLevel={zoomLevel}
/>
))}
</div>
</ScrollArea>
</div>
);
}
export function TimelineBookmark({
time,
zoomLevel,
}: {
time: number;
zoomLevel: number;
}) {
const editor = useEditor();
const handleBookmarkClick = ({
event,
}: {
event: React.MouseEvent<HTMLDivElement>;
}) => {
event.stopPropagation();
editor.playback.seek({ time });
};
return (
<div
className="absolute top-0 h-10 w-0.5 cursor-pointer"
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
onClick={(event) => handleBookmarkClick({ event })}
>
<div className="text-primary absolute left-[-5px] top-[-1px]">
<Bookmark className="fill-primary size-3" />
</div>
</div>
);
}
@@ -1,21 +0,0 @@
import { getDropLineY } from "@/lib/timeline/drop-utils";
import type { TimelineTrack, DropTarget } from "@/types/timeline";
interface DragLineProps {
dropTarget: DropTarget | null;
tracks: TimelineTrack[];
isVisible: boolean;
}
export function DragLine({ dropTarget, tracks, isVisible }: DragLineProps) {
if (!isVisible || !dropTarget) return null;
const y = getDropLineY({ dropTarget, tracks });
return (
<div
className="bg-primary pointer-events-none absolute left-0 right-0 z-50 h-0.5"
style={{ top: `${y}px` }}
/>
);
}
@@ -26,6 +26,8 @@ import {
getTrackHeight,
getCumulativeHeightBefore,
getTotalTracksHeight,
canTracktHaveAudio,
canTrackBeHidden,
} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
import { useScrollSync } from "@/hooks/use-scroll-sync";
@@ -33,16 +35,18 @@ import { useElementSelection } from "@/hooks/use-element-selection";
import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { TimelineRuler } from "./timeline-ruler";
import { DragLine } from "./drag-line";
import { TimelineBookmarksRow } from "./bookmarks";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
export function Timeline() {
const tracksContainerHeight = { min: 200, max: 800 };
const { snappingEnabled } = useTimelineStore();
const { clearSelection, setSelection } = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
const tracks = timeline.getTracks();
const seek = (time: number) => editor.playback.seek({ time });
// Refs
@@ -54,6 +58,7 @@ export function Timeline() {
const trackLabelsRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
const bookmarksScrollRef = useRef<HTMLDivElement>(null);
// State
const [isInTimeline, setIsInTimeline] = useState(false);
@@ -82,14 +87,11 @@ export function Timeline() {
onSnapPointChange: handleSnapPointChange,
});
const timelineDuration = timeline.getTotalDuration() || 0;
const paddedDuration =
timelineDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS;
const dynamicTimelineWidth = Math.max(
(timeline.getTotalDuration() || 0) *
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
zoomLevel,
(editor.playback.getCurrentTime() +
TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
zoomLevel,
paddedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
timelineRef.current?.clientWidth || 1000,
);
@@ -101,7 +103,7 @@ export function Timeline() {
playheadRef,
});
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
const { dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
zoomLevel,
});
@@ -110,13 +112,13 @@ export function Timeline() {
selectionBox,
handleMouseDown: handleSelectionMouseDown,
isSelecting,
justFinishedSelecting,
} = useSelectionBox({
containerRef: tracksContainerRef,
playheadRef,
onSelectionComplete: (elements) => {
setSelection(elements);
},
tracksScrollRef,
zoomLevel,
});
const showSnapIndicator =
@@ -130,7 +132,6 @@ export function Timeline() {
zoomLevel,
duration: timeline.getTotalDuration(),
isSelecting,
justFinishedSelecting,
clearSelectedElements: clearSelection,
seek,
});
@@ -139,6 +140,7 @@ export function Timeline() {
rulerScrollRef,
tracksScrollRef,
trackLabelsScrollRef,
bookmarksScrollRef,
});
return (
@@ -174,31 +176,45 @@ export function Timeline() {
<SnapIndicator
snapPoint={currentSnapPoint}
zoomLevel={zoomLevel}
tracks={timeline.getTracks()}
tracks={tracks}
timelineRef={timelineRef}
trackLabelsRef={trackLabelsRef}
tracksScrollRef={tracksScrollRef}
isVisible={showSnapIndicator}
/>
<div className="bg-panel sticky top-0 z-10 flex">
<div className="bg-panel flex w-28 shrink-0 items-center justify-between border-r px-3 py-2">
<span className="opacity-0">.</span>
</div>
<div className="bg-panel sticky top-0 z-10 flex flex-col">
<div className="flex">
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
<span className="opacity-0">.</span>
</div>
<TimelineRuler
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
rulerRef={rulerRef}
rulerScrollRef={rulerScrollRef}
handleWheel={handleWheel}
handleSelectionMouseDown={handleSelectionMouseDown}
handleTimelineContentClick={handleTimelineContentClick}
handleRulerMouseDown={handleRulerMouseDown}
/>
<TimelineRuler
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
rulerRef={rulerRef}
rulerScrollRef={rulerScrollRef}
handleWheel={handleWheel}
handleTimelineContentClick={handleTimelineContentClick}
handleRulerMouseDown={handleRulerMouseDown}
/>
</div>
<div className="flex">
<div className="bg-panel flex h-6 w-28 shrink-0 items-center justify-between border-r px-3">
<span className="opacity-0">.</span>
</div>
<TimelineBookmarksRow
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
bookmarksScrollRef={bookmarksScrollRef}
handleWheel={handleWheel}
handleTimelineContentClick={handleTimelineContentClick}
handleRulerMouseDown={handleRulerMouseDown}
/>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{timeline.getTracks().length > 0 && (
{tracks.length > 0 && (
<div
ref={trackLabelsRef}
className="z-100 bg-panel w-28 shrink-0 overflow-y-auto border-r"
@@ -206,7 +222,7 @@ export function Timeline() {
>
<ScrollArea className="h-full w-full" ref={trackLabelsScrollRef}>
<div className="flex flex-col gap-1">
{timeline.getTracks().map((track) => (
{tracks.map((track) => (
<div
key={track.id}
className="group flex items-center px-3"
@@ -215,26 +231,39 @@ export function Timeline() {
}}
>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{track.muted ? (
<VolumeOff
className="text-destructive h-4 w-4 cursor-pointer"
onClick={() =>
timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
) : (
<Volume2
{canTracktHaveAudio(track) && (
<>
{track.muted ? (
<VolumeOff
className="text-destructive h-4 w-4 cursor-pointer"
onClick={() =>
timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
) : (
<Volume2
className="text-muted-foreground h-4 w-4 cursor-pointer"
onClick={() =>
timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
)}
</>
)}
{canTrackBeHidden(track) && (
<Eye
className="text-muted-foreground h-4 w-4 cursor-pointer"
onClick={() =>
timeline.toggleTrackMute({
timeline.toggleTrackHidden({
trackId: track.id,
})
}
/>
)}
<Eye className="text-muted-foreground h-4 w-4" />
<TrackIcon track={track} />
</div>
</div>
@@ -265,50 +294,36 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
<DragLine
dropTarget={dropTarget}
tracks={timeline.getTracks()}
isVisible={isDragOver}
/>
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
style={{
height: `${Math.max(
200,
tracksContainerHeight.min,
Math.min(
800,
getTotalTracksHeight({ tracks: timeline.getTracks() }),
tracksContainerHeight.max,
getTotalTracksHeight({ tracks }),
),
)}px`,
width: `${dynamicTimelineWidth}px`,
}}
>
{timeline.getTracks().length === 0 ? (
{tracks.length === 0 ? (
<div />
) : (
<>
{timeline.getTracks().map((track, index) => (
{tracks.map((track, index) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
className="absolute left-0 right-0"
style={{
top: `${getCumulativeHeightBefore({
tracks: timeline.getTracks(),
tracks,
trackIndex: index,
})}px`,
height: `${getTrackHeight({ type: track.type })}px`,
}}
onClick={(e) => {
if (
!(e.target as HTMLElement).closest(
".timeline-element",
)
) {
clearSelection();
}
}}
>
<TimelineTrackContent
track={track}
@@ -331,7 +346,9 @@ export function Timeline() {
});
}}
>
{track.muted ? "Unmute Track" : "Mute Track"}
{canTracktHaveAudio(track) && track.muted
? "Unmute track"
: "Mute track"}
</ContextMenuItem>
<ContextMenuItem onClick={(e) => e.stopPropagation()}>
Track settings (soon)
@@ -21,8 +21,8 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
getTrackClasses,
getTrackHeight,
canHaveAudio,
canBeHidden,
canElementHaveAudio,
canElementBeHidden,
hasMediaId,
} from "@/lib/timeline";
import {
@@ -88,10 +88,8 @@ export function TimelineElement({
selected.elementId === element.id && selected.trackId === track.id,
);
const elementWidth = Math.max(
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
element.duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
);
const elementWidth =
element.duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const isBeingDragged = dragState.elementId === element.id;
const elementStartTime =
@@ -118,7 +116,7 @@ export function TimelineElement({
}
};
const isMuted = canHaveAudio(element) && element.muted === true;
const isMuted = canElementHaveAudio(element) && element.muted === true;
return (
<ContextMenu>
@@ -160,7 +158,7 @@ export function TimelineElement({
selectedCount={selectedElements.length}
onClick={(event) => handleAction({ action: "copy-selected", event })}
/>
{canHaveAudio(element) && hasAudio && (
{canElementHaveAudio(element) && hasAudio && (
<MuteMenuItem
element={element}
isMultipleSelected={selectedElements.length > 1}
@@ -172,7 +170,7 @@ export function TimelineElement({
}
/>
)}
{canBeHidden(element) && (
{canElementBeHidden(element) && (
<VisibilityMenuItem
element={element}
isMultipleSelected={selectedElements.length > 1}
@@ -262,7 +260,7 @@ function ElementInner({
{
type: track.type,
},
)} ${isBeingDragged ? "z-50" : "z-10"} ${canBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
)} ${isBeingDragged ? "z-50" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
onClick={(e) => onElementClick(e, element)}
onMouseDown={(e) => onElementMouseDown(e, element)}
onContextMenu={(e) => onElementMouseDown(e, element)}
@@ -276,7 +274,7 @@ function ElementInner({
/>
</div>
{(hasAudio ? isMuted : canBeHidden(element) && element.hidden) && (
{(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
{hasAudio ? (
<VolumeX className="size-6 text-white" />
@@ -397,13 +395,15 @@ function ElementContent({
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
>
<div
className="absolute bottom-[0.25rem] left-0 right-0 top-[0.25rem]"
className="absolute left-0 right-0"
style={{
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
top: isSelected ? "0.25rem" : "0rem",
bottom: isSelected ? "0.25rem" : "0rem",
}}
aria-label={`Tiled ${mediaAsset.type === "image" ? "background" : "thumbnail"} of ${mediaAsset.name}`}
/>
@@ -493,7 +493,7 @@ function VisibilityMenuItem({
selectedCount: number;
onClick: (e: React.MouseEvent) => void;
}) {
const isHidden = canBeHidden(element) && element.hidden;
const isHidden = canElementBeHidden(element) && element.hidden;
const getIcon = () => {
if (isMultipleSelected && isCurrentElementSelected) {
@@ -1,7 +1,7 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import { Bookmark } from "lucide-react";
import { TimelineTick } from "./timeline-tick";
interface TimelineRulerProps {
@@ -10,7 +10,6 @@ interface TimelineRulerProps {
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
handleWheel: (e: React.WheelEvent) => void;
handleSelectionMouseDown: (e: React.MouseEvent) => void;
handleTimelineContentClick: (e: React.MouseEvent) => void;
handleRulerMouseDown: (e: React.MouseEvent) => void;
}
@@ -21,112 +20,80 @@ export function TimelineRuler({
rulerRef,
rulerScrollRef,
handleWheel,
handleSelectionMouseDown,
handleTimelineContentClick,
handleRulerMouseDown,
}: TimelineRulerProps) {
const editor = useEditor();
const activeScene = editor.scenes.getActiveScene();
const duration = editor.timeline.getTotalDuration();
const interval = getOptimalTimeInterval({ zoomLevel });
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const tickSpacingPixels = interval * pixelsPerSecond;
const minLabelSpacingPixels = 120;
const labelEvery = Math.max(
1,
Math.ceil(minLabelSpacingPixels / tickSpacingPixels),
);
const markerCount = Math.ceil(duration / interval) + 1;
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration);
const project = editor.project.getActiveOrNull();
const fps = project?.settings.fps ?? DEFAULT_FPS;
const interval = getOptimalTimeInterval({ zoomLevel, fps });
const markerCount = Math.ceil(effectiveDuration / interval) + 1;
const timelineTicks: Array<JSX.Element> = [];
for (let markerIndex = 0; markerIndex < markerCount; markerIndex += 1) {
const time = markerIndex * interval;
if (time > duration) break;
if (time > effectiveDuration) break;
timelineTicks.push(
<TimelineTick
key={markerIndex}
time={time}
zoomLevel={zoomLevel}
interval={interval}
shouldShowLabel={markerIndex % labelEvery === 0}
/>,
<TimelineTick key={markerIndex} time={time} zoomLevel={zoomLevel} />,
);
}
return (
<div
className="relative h-10 flex-1 overflow-hidden"
className="relative h-4 flex-1 overflow-hidden"
onWheel={handleWheel}
onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick}
data-ruler-area
>
<ScrollArea className="w-full" ref={rulerScrollRef}>
<ScrollArea className="scrollbar-hidden w-full" ref={rulerScrollRef}>
<div
ref={rulerRef}
className="relative h-10 cursor-default select-none"
className="relative h-4 cursor-default select-none"
style={{
width: `${dynamicTimelineWidth}px`,
}}
onMouseDown={handleRulerMouseDown}
>
{timelineTicks}
{activeScene.bookmarks.map((time: number, index: number) => (
<TimelineBookmark
key={`bookmark-${index}`}
time={time}
zoomLevel={zoomLevel}
/>
))}
</div>
</ScrollArea>
</div>
);
}
function TimelineBookmark({
time,
function getOptimalTimeInterval({
zoomLevel,
fps,
}: {
time: number;
zoomLevel: number;
fps: number;
}) {
const editor = useEditor();
const handleBookmarkClick = ({
event,
}: {
event: React.MouseEvent<HTMLDivElement>;
}) => {
event.stopPropagation();
editor.playback.seek({ time });
};
return (
<div
className="!bg-primary absolute top-0 h-10 w-0.5 cursor-pointer"
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
onClick={(event) => handleBookmarkClick({ event })}
>
<div className="text-primary absolute left-[-5px] top-[-1px]">
<Bookmark className="fill-primary size-3" />
</div>
</div>
);
}
function getOptimalTimeInterval({ zoomLevel }: { zoomLevel: number }) {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
if (pixelsPerSecond >= 200) return 0.1;
if (pixelsPerSecond >= 100) return 0.5;
if (pixelsPerSecond >= 50) return 1;
if (pixelsPerSecond >= 25) return 2;
if (pixelsPerSecond >= 12) return 5;
if (pixelsPerSecond >= 6) return 10;
return 30;
const pixelsPerFrame = pixelsPerSecond / fps;
const minPixelSpacing = 18;
const minFrames = minPixelSpacing / pixelsPerFrame;
const baseIntervals = [1, 3, 6, 12, 15, 30];
const maxInterval = fps * 10;
const niceIntervals: Array<number> = [...baseIntervals];
let currentInterval = baseIntervals[baseIntervals.length - 1];
while (currentInterval < maxInterval) {
currentInterval *= 2;
niceIntervals.push(currentInterval);
}
for (const intervalFrames of niceIntervals) {
if (intervalFrames >= minFrames) {
return intervalFrames / fps;
}
}
return niceIntervals[niceIntervals.length - 1] / fps;
}
@@ -5,61 +5,15 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface TimelineTickProps {
time: number;
zoomLevel: number;
interval: number;
shouldShowLabel: boolean;
}
export function TimelineTick({
time,
zoomLevel,
interval,
shouldShowLabel,
}: TimelineTickProps) {
export function TimelineTick({ time, zoomLevel }: TimelineTickProps) {
return (
<div
className="border-muted-foreground/20 absolute top-0 h-4 border-l"
className="border-muted-foreground/40 absolute top-1 h-1.5 border-l"
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
>
{shouldShowLabel ? (
<span className="text-muted-foreground/70 absolute left-1 top-1 text-[0.6rem]">
{formatTimelineTickLabel({ timeInSeconds: time, interval })}
</span>
) : null}
</div>
></div>
);
}
function formatTimelineTickLabel({
timeInSeconds,
interval,
}: {
timeInSeconds: number;
interval: number;
}): string {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const secondsRemainder = timeInSeconds % 60;
if (hours > 0) {
const paddedMinutes = minutes.toString().padStart(2, "0");
const paddedSeconds = Math.floor(secondsRemainder)
.toString()
.padStart(2, "0");
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
}
if (minutes > 0) {
const paddedSeconds = Math.floor(secondsRemainder)
.toString()
.padStart(2, "0");
return `${minutes}:${paddedSeconds}`;
}
if (interval >= 1) {
return `${Math.floor(secondsRemainder)}s`;
}
return `${secondsRemainder.toFixed(1)}s`;
}
@@ -98,43 +98,21 @@ function ToolbarLeftSection() {
return (
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={(event) =>
handleAction({ action: "toggle-play", event })
}
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<ToolbarButton
icon={isPlaying ? <Pause /> : <Play />}
tooltip={isPlaying ? "Pause" : "Play"}
onClick={({ event }) =>
handleAction({ action: "toggle-play", event })
}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={(event) => handleAction({ action: "goto-start", event })}
>
<SkipBack className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<SkipBack />}
tooltip="Go to start"
onClick={({ event }) => handleAction({ action: "goto-start", event })}
/>
<div className="bg-border mx-1 h-6 w-px" />
<div className="bg-border mx-2 h-10 w-px" />
<TimeDisplay
currentTime={currentTime}
@@ -142,137 +120,76 @@ function ToolbarLeftSection() {
fps={activeProject.settings.fps}
/>
<div className="bg-border mx-1 h-6 w-px" />
<div className="bg-border mx-1 h-10 w-px" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() => {
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
});
}}
>
<Scissors className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<Scissors />}
tooltip="Split element"
onClick={({ event }) =>
handleAction({ action: "split-selected", event })
}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() => {
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
retainSide: "left",
});
}}
>
<ArrowLeftToLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<ArrowLeftToLine />}
tooltip="Split and keep left"
onClick={({ event }) =>
handleAction({ action: "split-selected-left", event })
}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() => {
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
retainSide: "right",
});
}}
>
<ArrowRightToLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<ArrowRightToLine />}
tooltip="Split and keep right"
onClick={({ event }) =>
handleAction({ action: "split-selected-right", event })
}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" disabled type="button">
<SplitSquareHorizontal className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<SplitSquareHorizontal />}
tooltip="Coming soon" /* Separate audio */
disabled={true}
onClick={({ event }) => {}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={(event) =>
handleAction({ action: "duplicate-selected", event })
}
>
<Copy className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<Copy />}
tooltip="Duplicate element"
onClick={({ event }) =>
handleAction({ action: "duplicate-selected", event })
}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" type="button" disabled>
<Snowflake className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F) (Coming soon)</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<Snowflake />}
tooltip="Coming soon" /* Freeze frame */
disabled={true}
onClick={({ event }) => {}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={(event) =>
handleAction({ action: "delete-selected", event })
}
>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<Trash2 />}
tooltip="Delete element"
onClick={({ event }) =>
handleAction({ action: "delete-selected", event })
}
/>
<div className="bg-border mx-1 h-6 w-px" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={(event) =>
handleAction({ action: "toggle-bookmark", event })
}
>
<ToolbarButton
icon={
<Bookmark
className={`size-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
className={currentBookmarked ? "fill-primary text-primary" : ""}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
</TooltipContent>
}
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event })
}
/>
</Tooltip>
</TooltipProvider>
</div>
@@ -348,23 +265,17 @@ function ToolbarRightSection({
return (
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" type="button" onClick={() => {}}>
<Magnet className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Auto snapping</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<Magnet />}
tooltip="Auto snapping"
onClick={() => {}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" type="button" onClick={() => {}}>
<Link className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Enable Ripple Editing</TooltipContent>
</Tooltip>
<ToolbarButton
icon={<Link />}
tooltip="Ripple editing"
onClick={() => {}}
/>
</TooltipProvider>
<div className="bg-border mx-1 h-6 w-px" />
@@ -376,7 +287,7 @@ function ToolbarRightSection({
type="button"
onClick={() => onZoom({ direction: "out" })}
>
<ZoomOut className="size-4" />
<ZoomOut />
</Button>
<Slider
className="w-24"
@@ -392,9 +303,38 @@ function ToolbarRightSection({
type="button"
onClick={() => onZoom({ direction: "in" })}
>
<ZoomIn className="size-4" />
<ZoomIn />
</Button>
</div>
</div>
);
}
function ToolbarButton({
icon,
tooltip,
onClick,
disabled,
}: {
icon: React.ReactNode;
tooltip: string;
onClick: ({ event }: { event: React.MouseEvent }) => void;
disabled?: boolean;
}) {
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={(event) => onClick({ event })}
className={disabled ? "cursor-not-allowed opacity-50" : ""}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
@@ -106,7 +106,7 @@ export function KeyboardShortcutsHelp() {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="text" size="sm" className="gap-2">
<Keyboard className="w-4 h-4" />
<Keyboard />
Shortcuts
</Button>
</DialogTrigger>
+1 -1
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,
@@ -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>
);
}