refactor: restructure files to their domains, new preview overlay system, and dep graph

This commit is contained in:
Maze Winther 2026-04-20 11:31:17 +02:00
parent 729d10592f
commit 3e89d29985
491 changed files with 3565 additions and 2372 deletions

View File

@ -5,8 +5,8 @@ import { toast } from "sonner";
import { import {
type KeyboardShortcut, type KeyboardShortcut,
useKeyboardShortcutsHelp, useKeyboardShortcutsHelp,
} from "@/hooks/use-keyboard-shortcuts-help"; } from "@/actions/use-keyboard-shortcuts-help";
import { useKeybindingsStore } from "@/stores/keybindings-store"; import { useKeybindingsStore } from "@/actions/keybindings-store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,

View File

@ -1,7 +1,7 @@
import type { import type {
KeybindingConfig, KeybindingConfig,
ShortcutKey, ShortcutKey,
} from "@/lib/actions/keybinding"; } from "@/actions/keybinding";
import type { TActionWithOptionalArgs } from "./types"; import type { TActionWithOptionalArgs } from "./types";
export type TActionCategory = export type TActionCategory =

View File

@ -2,11 +2,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { TActionWithOptionalArgs } from "@/lib/actions"; import type { TActionWithOptionalArgs } from "@/actions";
import { getDefaultShortcuts } from "@/lib/actions"; import { getDefaultShortcuts } from "@/actions";
import { isTypableDOMElement } from "@/utils/browser"; import { isTypableDOMElement } from "@/utils/browser";
import { isAppleDevice } from "@/utils/platform"; import { isAppleDevice } from "@/utils/platform";
import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding"; import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();

View File

@ -1,5 +1,5 @@
import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding"; import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
import type { TActionWithOptionalArgs } from "@/lib/actions"; import type { TActionWithOptionalArgs } from "@/actions";
interface V2State { interface V2State {
keybindings: KeybindingConfig; keybindings: KeybindingConfig;

View File

@ -1,6 +1,6 @@
import type { TActionWithOptionalArgs } from "@/lib/actions"; import type { TActionWithOptionalArgs } from "@/actions";
import type { ShortcutKey } from "@/lib/actions/keybinding"; import type { ShortcutKey } from "@/actions/keybinding";
import type { KeybindingConfig } from "@/lib/actions/keybinding"; import type { KeybindingConfig } from "@/actions/keybinding";
interface V3State { interface V3State {
keybindings: KeybindingConfig; keybindings: KeybindingConfig;

View File

@ -1,4 +1,4 @@
import type { KeybindingConfig } from "@/lib/actions/keybinding"; import type { KeybindingConfig } from "@/actions/keybinding";
interface V4State { interface V4State {
keybindings: KeybindingConfig; keybindings: KeybindingConfig;

View File

@ -1,4 +1,4 @@
import type { KeybindingConfig } from "@/lib/actions/keybinding"; import type { KeybindingConfig } from "@/actions/keybinding";
interface V5State { interface V5State {
keybindings: KeybindingConfig; keybindings: KeybindingConfig;

View File

@ -1,4 +1,4 @@
import type { KeybindingConfig } from "@/lib/actions/keybinding"; import type { KeybindingConfig } from "@/actions/keybinding";
interface V6State { interface V6State {
keybindings: KeybindingConfig; keybindings: KeybindingConfig;

View File

@ -5,8 +5,8 @@ import type {
TActionHandlerOptions, TActionHandlerOptions,
TArgOfAction, TArgOfAction,
TInvocationTrigger, TInvocationTrigger,
} from "@/lib/actions"; } from "@/actions";
import { bindAction, unbindAction } from "@/lib/actions"; import { bindAction, unbindAction } from "@/actions";
export function useActionHandler<A extends TAction>( export function useActionHandler<A extends TAction>(
action: A, action: A,

View File

@ -1,21 +1,21 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/timeline/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler"; import { useActionHandler } from "@/actions/use-action-handler";
import { useEditor } from "../use-editor"; import { useEditor } from "@/editor/use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
import { TICKS_PER_SECOND } from "@/lib/wasm"; import { TICKS_PER_SECOND } from "@/wasm";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
import { getElementsAtTime, hasMediaId } from "@/lib/timeline"; import { getElementsAtTime, hasMediaId } from "@/timeline";
import { cancelInteraction } from "@/lib/cancel-interaction"; import { cancelInteraction } from "@/editor/cancel-interaction";
import { invokeAction } from "@/lib/actions"; import { invokeAction } from "@/actions";
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation"; import { canToggleSourceAudio } from "@/timeline/audio-separation";
import { import {
activateScope, activateScope,
clearActiveScope, clearActiveScope,
type ScopeEntry, type ScopeEntry,
} from "@/lib/selection/scope"; } from "@/selection/scope";
export function useEditorActions() { export function useEditorActions() {
const editor = useEditor(); const editor = useEditor();

View File

@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { invokeAction } from "@/lib/actions"; import { invokeAction } from "@/actions";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useKeybindingsStore } from "@/stores/keybindings-store"; import { useKeybindingsStore } from "@/actions/keybindings-store";
import { isTypableDOMElement } from "@/utils/browser"; import { isTypableDOMElement } from "@/utils/browser";
/** /**

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { useKeybindingsStore } from "@/stores/keybindings-store"; import { useKeybindingsStore } from "@/actions/keybindings-store";
import { ACTIONS, type TActionWithOptionalArgs } from "@/lib/actions"; import { ACTIONS, type TActionWithOptionalArgs } from "@/actions";
import { import {
getPlatformAlternateKey, getPlatformAlternateKey,
getPlatformSpecialKey, getPlatformSpecialKey,

View File

@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { import {
composeAnimationValue, composeAnimationValue,
createAnimationBinding, createAnimationBinding,
} from "@/lib/animation/binding-values"; } from "@/animation/binding-values";
describe("binding values", () => { describe("binding values", () => {
test("formats composed animated colors as hex", () => { test("formats composed animated colors as hex", () => {

View File

@ -1,4 +1,4 @@
import type { ScalarAnimationKey } from "@/lib/animation/types"; import type { ScalarAnimationKey } from "@/animation/types";
const BEZIER_SOLVE_ITERATIONS = 20; const BEZIER_SOLVE_ITERATIONS = 20;

View File

@ -12,7 +12,7 @@ import type {
DiscreteValue, DiscreteValue,
Vector2AnimationBinding, Vector2AnimationBinding,
VectorValue, VectorValue,
} from "@/lib/animation/types"; } from "@/animation/types";
import { clamp } from "@/utils/math"; import { clamp } from "@/utils/math";
interface LinearRgba { interface LinearRgba {

View File

@ -1,12 +1,12 @@
import { import {
getDefaultLeftHandle, getDefaultLeftHandle,
getDefaultRightHandle, getDefaultRightHandle,
} from "@/lib/animation/bezier"; } from "@/animation/bezier";
import type { import type {
CurveHandle, CurveHandle,
NormalizedCubicBezier, NormalizedCubicBezier,
ScalarAnimationKey, ScalarAnimationKey,
} from "@/lib/animation/types"; } from "@/animation/types";
const VALUE_EPSILON = 1e-6; const VALUE_EPSILON = 1e-6;

View File

@ -1,9 +1,9 @@
import type { ParamValues } from "@/lib/params"; import type { ParamValues } from "@/params";
import type { Effect } from "@/lib/effects/types"; import type { Effect } from "@/effects/types";
import type { import type {
ElementAnimations, ElementAnimations,
EffectParamPath, EffectParamPath,
} from "@/lib/animation/types"; } from "@/animation/types";
import { removeElementKeyframe } from "./keyframes"; import { removeElementKeyframe } from "./keyframes";
import { resolveAnimationPathValueAtTime } from "./resolve"; import { resolveAnimationPathValueAtTime } from "./resolve";

View File

@ -5,7 +5,7 @@ import type {
ScalarAnimationChannel, ScalarAnimationChannel,
ScalarGraphChannel, ScalarGraphChannel,
ScalarGraphKeyframeContext, ScalarGraphKeyframeContext,
} from "@/lib/animation/types"; } from "@/animation/types";
export interface EditableScalarChannels { export interface EditableScalarChannels {
binding: AnimationBindingInstance; binding: AnimationBindingInstance;

View File

@ -1,12 +1,12 @@
import type { import type {
ElementAnimations, ElementAnimations,
GraphicParamPath, GraphicParamPath,
} from "@/lib/animation/types"; } from "@/animation/types";
import type { ParamValues } from "@/lib/params"; import type { ParamValues } from "@/params";
import { import {
getGraphicDefinition, getGraphicDefinition,
resolveGraphicParams, resolveGraphicParams,
} from "@/lib/graphics"; } from "@/graphics";
import { resolveAnimationPathValueAtTime } from "./resolve"; import { resolveAnimationPathValueAtTime } from "./resolve";
export const GRAPHIC_PARAM_PATH_PREFIX = "params."; export const GRAPHIC_PARAM_PATH_PREFIX = "params.";

View File

@ -7,7 +7,7 @@ import type {
ScalarAnimationChannel, ScalarAnimationChannel,
ScalarAnimationKey, ScalarAnimationKey,
ScalarSegmentType, ScalarSegmentType,
} from "@/lib/animation/types"; } from "@/animation/types";
import { clamp } from "@/utils/math"; import { clamp } from "@/utils/math";
import { import {
getBezierPoint, getBezierPoint,

View File

@ -4,7 +4,7 @@ import type {
AnimationPath, AnimationPath,
ElementAnimations, ElementAnimations,
ElementKeyframe, ElementKeyframe,
} from "@/lib/animation/types"; } from "@/animation/types";
import { import {
type AnimationComponentValue, type AnimationComponentValue,
composeAnimationValue, composeAnimationValue,

View File

@ -13,7 +13,7 @@ import type {
ScalarAnimationKey, ScalarAnimationKey,
ScalarCurveKeyframePatch, ScalarCurveKeyframePatch,
ScalarSegmentType, ScalarSegmentType,
} from "@/lib/animation/types"; } from "@/animation/types";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { import {
cloneAnimationBinding, cloneAnimationBinding,

View File

@ -2,8 +2,8 @@ import type {
AnimationPropertyGroup, AnimationPropertyGroup,
AnimationPropertyPath, AnimationPropertyPath,
ElementAnimations, ElementAnimations,
} from "@/lib/animation/types"; } from "@/animation/types";
import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types"; import { ANIMATION_PROPERTY_GROUPS } from "@/animation/types";
import { getKeyframeAtTime } from "./keyframe-query"; import { getKeyframeAtTime } from "./keyframe-query";
export interface GroupKeyframeRef { export interface GroupKeyframeRef {

View File

@ -3,20 +3,20 @@ import type {
AnimationInterpolation, AnimationInterpolation,
AnimationPropertyPath, AnimationPropertyPath,
AnimationValue, AnimationValue,
} from "@/lib/animation/types"; } from "@/animation/types";
import { parseColorToLinearRgba } from "./binding-values"; import { parseColorToLinearRgba } from "./binding-values";
import type { TimelineElement } from "@/lib/timeline"; import type { TimelineElement } from "@/timeline";
import { MIN_TRANSFORM_SCALE } from "@/lib/animation/transform"; import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
import { import {
CORNER_RADIUS_MAX, CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN, CORNER_RADIUS_MIN,
} from "@/lib/text/background"; } from "@/text/background";
import { import {
canElementHaveAudio, canElementHaveAudio,
isVisualElement, isVisualElement,
} from "@/lib/timeline/element-utils"; } from "@/timeline/element-utils";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/timeline/defaults";
import { snapToStep } from "@/utils/math"; import { snapToStep } from "@/utils/math";
export interface NumericSpec { export interface NumericSpec {

View File

@ -5,8 +5,8 @@ import type {
AnimationPropertyPath, AnimationPropertyPath,
AnimationValueForPath, AnimationValueForPath,
ElementAnimations, ElementAnimations,
} from "@/lib/animation/types"; } from "@/animation/types";
import type { Transform } from "@/lib/rendering"; import type { Transform } from "@/rendering";
import { import {
type AnimationComponentValue, type AnimationComponentValue,
composeAnimationValue, composeAnimationValue,

View File

@ -3,20 +3,20 @@ import type {
AnimationInterpolation, AnimationInterpolation,
AnimationPath, AnimationPath,
AnimationValue, AnimationValue,
} from "@/lib/animation/types"; } from "@/animation/types";
import { import {
parseEffectParamPath, parseEffectParamPath,
isEffectParamPath, isEffectParamPath,
} from "@/lib/animation/effect-param-channel"; } from "@/animation/effect-param-channel";
import { import {
isGraphicParamPath, isGraphicParamPath,
parseGraphicParamPath, parseGraphicParamPath,
} from "@/lib/animation/graphic-param-channel"; } from "@/animation/graphic-param-channel";
import type { ParamDefinition } from "@/lib/params"; import type { ParamDefinition } from "@/params";
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; import { effectsRegistry, registerDefaultEffects } from "@/effects";
import { getGraphicDefinition } from "@/lib/graphics"; import { getGraphicDefinition } from "@/graphics";
import type { TimelineElement } from "@/lib/timeline"; import type { TimelineElement } from "@/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils"; import { isVisualElement } from "@/timeline/element-utils";
import { snapToStep } from "@/utils/math"; import { snapToStep } from "@/utils/math";
import { import {
coerceAnimationValueForProperty, coerceAnimationValueForProperty,

View File

@ -0,0 +1,35 @@
import { getElementKeyframes } from "@/animation";
import type { SceneTracks } from "@/timeline";
import type { SnapPoint } from "@/timeline/snapping";
export function getAnimationKeyframeSnapPointsForTimeline({
tracks,
excludeElementIds,
}: {
tracks: SceneTracks;
excludeElementIds?: Set<string>;
}): SnapPoint[] {
const snapPoints: SnapPoint[] = [];
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
for (const track of orderedTracks) {
for (const element of track.elements) {
if (excludeElementIds?.has(element.id)) {
continue;
}
for (const keyframe of getElementKeyframes({
animations: element.animations,
})) {
snapPoints.push({
time: element.startTime + keyframe.time,
type: "keyframe",
elementId: element.id,
trackId: track.id,
});
}
}
}
return snapPoints;
}

View File

@ -1,4 +1,4 @@
import type { ParamValues } from "@/lib/params"; import type { ParamValues } from "@/params";
export const ANIMATION_PROPERTY_PATHS = [ export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX", "transform.positionX",

View File

@ -1,4 +1,4 @@
import { auth } from "@/lib/auth/server"; import { auth } from "@/auth/server";
import { toNextJsHandler } from "better-auth/next-js"; import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth); export const { POST, GET } = toNextJsHandler(auth);

View File

@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit"; import { checkRateLimit } from "@/auth/rate-limit";
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/lib/feedback"; import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback";
const submitSchema = z.object({ const submitSchema = z.object({
message: z message: z

View File

@ -1,7 +1,7 @@
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@/env/web";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit"; import { checkRateLimit } from "@/auth/rate-limit";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(), q: z.string().max(500, "Query too long").optional(),

View File

@ -4,8 +4,8 @@ import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page"; import { BasePage } from "@/app/base-page";
import Prose from "@/components/ui/prose"; import Prose from "@/components/ui/prose";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query"; import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
import type { Author, Post } from "@/lib/blog/types"; import type { Author, Post } from "@/blog/types";
type PageProps = { type PageProps = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;

View File

@ -2,8 +2,8 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { BasePage } from "@/app/base-page"; import { BasePage } from "@/app/base-page";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query"; import { getPosts } from "@/blog/query";
import type { Post } from "@/lib/blog/types"; import type { Post } from "@/blog/types";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Blog - OpenCut", title: "Blog - OpenCut",

View File

@ -3,15 +3,15 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page"; import { BasePage } from "@/app/base-page";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { getReleaseByVersion, getSortedReleases } from "@/lib/changelog/utils"; import { getReleaseByVersion, getSortedReleases } from "@/changelog/utils";
import { import {
ReleaseArticle, ReleaseArticle,
ReleaseMeta, ReleaseMeta,
ReleaseTitle, ReleaseTitle,
ReleaseDescription, ReleaseDescription,
ReleaseChanges, ReleaseChanges,
} from "@/lib/changelog/components/release"; } from "@/changelog/components/release";
import { CopyMarkdownButton } from "@/lib/changelog/components/copy-markdown-button"; import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button";
type Props = { params: Promise<{ version: string }> }; type Props = { params: Promise<{ version: string }> };

View File

@ -4,14 +4,14 @@ import { Separator } from "@/components/ui/separator";
import { import {
type Release as ReleaseType, type Release as ReleaseType,
getSortedReleases, getSortedReleases,
} from "@/lib/changelog/utils"; } from "@/changelog/utils";
import { import {
ReleaseArticle, ReleaseArticle,
ReleaseMeta, ReleaseMeta,
ReleaseTitle, ReleaseTitle,
ReleaseDescription, ReleaseDescription,
ReleaseChanges, ReleaseChanges,
} from "@/lib/changelog/components/release"; } from "@/changelog/components/release";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Changelog - OpenCut", title: "Changelog - OpenCut",

View File

@ -3,7 +3,7 @@ import Link from "next/link";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section"; import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { EXTERNAL_TOOLS } from "@/lib/site/external-tools"; import { EXTERNAL_TOOLS } from "@/site/external-tools";
import { BasePage } from "../base-page"; import { BasePage } from "../base-page";
export const metadata: Metadata = { export const metadata: Metadata = {

View File

@ -8,21 +8,32 @@ import {
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/panels/assets"; import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/panels/properties"; import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/components/editor/panels/timeline"; import { Timeline } from "@/timeline/components";
import { PreviewPanel } from "@/components/editor/panels/preview"; import { PreviewPanel } from "@/preview/components";
import { EditorHeader } from "@/components/editor/editor-header"; import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider"; import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding"; import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; import { MigrationDialog } from "@/project/components/migration-dialog";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/editor/panel-store";
import { usePasteMedia } from "@/hooks/use-paste-media"; import { usePasteMedia } from "@/media/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate"; import { MobileGate } from "@/components/editor/mobile-gate";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/editor/use-editor";
import { Cancel01Icon } from "@hugeicons/core-free-icons"; import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification"; import { ChangelogNotification } from "@/changelog/components/changelog-notification";
import {
createPreviewOverlayControl,
isPreviewOverlayVisible,
mergePreviewOverlaySources,
} from "@/preview/overlays";
import { usePreviewStore } from "@/preview/preview-store";
import { getGuidePreviewOverlaySource } from "@/guides";
import {
bookmarkNotesPreviewOverlay,
getBookmarkPreviewOverlaySource,
} from "@/timeline/bookmarks/index";
export default function Editor() { export default function Editor() {
const params = useParams(); const params = useParams();
@ -70,6 +81,49 @@ function DegradedRendererBanner() {
function EditorLayout() { function EditorLayout() {
usePasteMedia(); usePasteMedia();
const { panels, setPanel } = usePanelStore(); const { panels, setPanel } = usePanelStore();
const activeScene = useEditor((editor) =>
editor.scenes.getActiveSceneOrNull(),
);
const currentTime = useEditor((editor) => editor.playback.getCurrentTime());
const activeGuide = usePreviewStore((state) => state.activeGuide);
const overlays = usePreviewStore((state) => state.overlays);
const setOverlayVisibility = usePreviewStore(
(state) => state.setOverlayVisibility,
);
const showBookmarkNotes = isPreviewOverlayVisible({
overlay: bookmarkNotesPreviewOverlay,
overlays,
});
const overlaySource = useMemo(
() =>
mergePreviewOverlaySources({
sources: [
getGuidePreviewOverlaySource({
guideId: activeGuide,
}),
activeScene
? getBookmarkPreviewOverlaySource({
bookmarks: activeScene.bookmarks,
time: currentTime,
isVisible: showBookmarkNotes,
})
: {
definitions: [bookmarkNotesPreviewOverlay],
instances: [],
},
],
}),
[activeGuide, activeScene, currentTime, showBookmarkNotes],
);
const overlayControls = useMemo(
() =>
overlaySource.definitions.map((overlay) =>
createPreviewOverlayControl({ overlay, overlays }),
),
[overlaySource.definitions, overlays],
);
return ( return (
<ResizablePanelGroup <ResizablePanelGroup
@ -111,7 +165,11 @@ function EditorLayout() {
minSize={30} minSize={30}
className="min-h-0 min-w-0 flex-1" className="min-h-0 min-w-0 flex-1"
> >
<PreviewPanel /> <PreviewPanel
overlayControls={overlayControls}
overlayInstances={overlaySource.instances}
onOverlayVisibilityChange={setOverlayVisibility}
/>
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle /> <ResizableHandle withHandle />

View File

@ -2,11 +2,11 @@ import { ThemeProvider } from "next-themes";
import Script from "next/script"; import Script from "next/script";
import "./globals.css"; import "./globals.css";
import { Toaster } from "../components/ui/sonner"; import { Toaster } from "../components/ui/sonner";
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification"; import { ChangelogNotification } from "@/changelog/components/changelog-notification";
import { TooltipProvider } from "../components/ui/tooltip"; import { TooltipProvider } from "../components/ui/tooltip";
import { baseMetaData } from "./metadata"; import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client"; import { BotIdClient } from "botid/client";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@/env/web";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] }); const siteFont = Inter({ subsets: ["latin"] });

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { SITE_INFO, SITE_URL } from "@/lib/site/brand"; import { SITE_INFO, SITE_URL } from "@/site/brand";
export const baseMetaData: Metadata = { export const baseMetaData: Metadata = {
metadataBase: new URL(SITE_URL), metadataBase: new URL(SITE_URL),

View File

@ -2,7 +2,7 @@ import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { Footer } from "@/components/footer"; import { Footer } from "@/components/footer";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { SITE_URL } from "@/lib/site/brand"; import { SITE_URL } from "@/site/brand";
export const metadata: Metadata = { export const metadata: Metadata = {
alternates: { alternates: {

View File

@ -7,7 +7,7 @@ import {
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/lib/site/social"; import { SOCIAL_LINKS } from "@/site/social";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Privacy Policy - OpenCut", title: "Privacy Policy - OpenCut",

View File

@ -7,20 +7,20 @@ import type { KeyboardEvent, MouseEvent } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { EditorCore } from "@/core"; import type { EditorCore } from "@/core";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; import { MigrationDialog } from "@/project/components/migration-dialog";
import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog"; import { StoragePersistenceDialog } from "@/services/storage/components/storage-persistence-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useProjectsStore } from "./store"; import { useProjectsStore } from "./store";
import type { import type {
TProjectMetadata, TProjectMetadata,
TProjectSortKey, TProjectSortKey,
TProjectSortOption, TProjectSortOption,
} from "@/lib/project/types"; } from "@/project/types";
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm"; import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
import { formatDate } from "@/utils/date"; import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
@ -62,11 +62,11 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog"; import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog"; import { ProjectInfoDialog } from "@/project/components/project-info-dialog";
import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog"; import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification"; import { ChangelogNotification } from "@/changelog/components/changelog-notification";
const formatProjectDuration = ({ const formatProjectDuration = ({
duration, duration,
}: { }: {

View File

@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { TProjectSortKey } from "@/lib/project/types"; import type { TProjectSortKey } from "@/project/types";
export type ProjectsViewMode = "grid" | "list"; export type ProjectsViewMode = "grid" | "list";

View File

@ -1,5 +1,5 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site/brand"; import { SITE_URL } from "@/site/brand";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {

View File

@ -1,6 +1,6 @@
import { Feed } from "feed"; import { Feed } from "feed";
import { getPosts } from "@/lib/blog/query"; import { getPosts } from "@/blog/query";
import { SITE_INFO, SITE_URL } from "@/lib/site/brand"; import { SITE_INFO, SITE_URL } from "@/site/brand";
export async function GET() { export async function GET() {
try { try {

View File

@ -1,5 +1,5 @@
import { SITE_URL } from "@/lib/site/brand"; import { SITE_URL } from "@/site/brand";
import { getPosts } from "@/lib/blog/query"; import { getPosts } from "@/blog/query";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {

View File

@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { BasePage } from "@/app/base-page"; import { BasePage } from "@/app/base-page";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { SPONSORS, type Sponsor } from "@/lib/site/sponsors"; import { SPONSORS, type Sponsor } from "@/site/sponsors";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { LinkSquare02Icon } from "@hugeicons/core-free-icons"; import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";

View File

@ -7,7 +7,7 @@ import {
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/lib/site/social"; import { SOCIAL_LINKS } from "@/site/social";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Terms of Service - OpenCut", title: "Terms of Service - OpenCut",

View File

@ -1,5 +1,5 @@
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@/env/web";
export const { signIn, signUp, useSession } = createAuthClient({ export const { signIn, signUp, useSession } = createAuthClient({
baseURL: webEnv.NEXT_PUBLIC_SITE_URL, baseURL: webEnv.NEXT_PUBLIC_SITE_URL,

View File

@ -1,6 +1,6 @@
import { Ratelimit } from "@upstash/ratelimit"; import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis"; import { Redis } from "@upstash/redis";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@/env/web";
const redis = new Redis({ const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL, url: webEnv.UPSTASH_REDIS_REST_URL,

View File

@ -1,8 +1,8 @@
import { betterAuth, type RateLimit } from "better-auth"; import { betterAuth, type RateLimit } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Redis } from "@upstash/redis"; import { Redis } from "@upstash/redis";
import { db } from "@/lib/db"; import { db } from "@/db";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@/env/web";
const redis = new Redis({ const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL, url: webEnv.UPSTASH_REDIS_REST_URL,

View File

@ -5,7 +5,7 @@ import type {
MarblePost, MarblePost,
MarblePostList, MarblePostList,
MarbleTagList, MarbleTagList,
} from "@/lib/blog/types"; } from "@/blog/types";
import { unified } from "unified"; import { unified } from "unified";
import rehypeParse from "rehype-parse"; import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify"; import rehypeStringify from "rehype-stringify";

View File

@ -1,4 +1,4 @@
import type { TCanvasSize } from "@/lib/project/types"; import type { TCanvasSize } from "@/project/types";
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [ export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
{ width: 1920, height: 1080 }, { width: 1920, height: 1080 },

View File

@ -1,4 +1,4 @@
import { PasteCommand } from "@/lib/commands/timeline"; import { PasteCommand } from "@/commands/timeline";
import type { ClipboardHandler } from "../types"; import type { ClipboardHandler } from "../types";
export const ElementsClipboardHandler = { export const ElementsClipboardHandler = {

View File

@ -1,7 +1,7 @@
import { getKeyframeById } from "@/lib/animation"; import { getKeyframeById } from "@/animation";
import type { SelectedKeyframeRef } from "@/lib/animation/types"; import type { SelectedKeyframeRef } from "@/animation/types";
import type { TimelineElement } from "@/lib/timeline"; import type { TimelineElement } from "@/timeline";
import { PasteKeyframesCommand } from "@/lib/commands/timeline"; import { PasteKeyframesCommand } from "@/commands/timeline";
import type { import type {
ClipboardHandler, ClipboardHandler,
KeyframeClipboardCurvePatch, KeyframeClipboardCurvePatch,

View File

@ -5,13 +5,13 @@ import type {
AnimationValue, AnimationValue,
ScalarCurveKeyframePatch, ScalarCurveKeyframePatch,
SelectedKeyframeRef, SelectedKeyframeRef,
} from "@/lib/animation/types"; } from "@/animation/types";
import type { Command } from "@/lib/commands/base-command"; import type { Command } from "@/commands/base-command";
import type { import type {
CreateTimelineElement, CreateTimelineElement,
ElementRef, ElementRef,
TrackType, TrackType,
} from "@/lib/timeline"; } from "@/timeline";
export interface ElementClipboardItem { export interface ElementClipboardItem {
trackId: string; trackId: string;

View File

@ -1,5 +1,5 @@
import type { EditorSelectionPatch } from "@/lib/selection/editor-selection"; import type { EditorSelectionPatch } from "@/selection/editor-selection";
import type { ElementRef } from "@/lib/timeline/types"; import type { ElementRef } from "@/timeline/types";
export interface CommandResult { export interface CommandResult {
selection?: EditorSelectionPatch; selection?: EditorSelectionPatch;

View File

@ -1,13 +1,13 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { toast } from "sonner"; import { toast } from "sonner";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/media/types";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service"; import { storageService } from "@/services/storage/service";
import type { FrameRate } from "opencut-wasm"; import type { FrameRate } from "opencut-wasm";
import { hasMediaId } from "@/lib/timeline/element-utils"; import { hasMediaId } from "@/timeline/element-utils";
import { frameRatesEqual, getHighestImportedVideoFps } from "@/lib/fps/utils"; import { frameRatesEqual, getHighestImportedVideoFps } from "@/fps/utils";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project"; import { UpdateProjectSettingsCommand } from "@/commands/project";
export class AddMediaAssetCommand extends Command { export class AddMediaAssetCommand extends Command {
private assetId: string; private assetId: string;

View File

@ -1,12 +1,12 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/media/types";
import { buildWaveformSourceKey } from "@/lib/media/waveform-summary"; import { buildWaveformSourceKey } from "@/media/waveform-summary";
import { storageService } from "@/services/storage/service"; import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service"; import { videoCache } from "@/services/video-cache/service";
import { waveformCache } from "@/services/waveform-cache/service"; import { waveformCache } from "@/services/waveform-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils"; import { hasMediaId } from "@/timeline/element-utils";
import type { SceneTracks } from "@/lib/timeline"; import type { SceneTracks } from "@/timeline";
export class RemoveMediaAssetCommand extends Command { export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null; private savedAssets: MediaAsset[] | null = null;

View File

@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TProject, TProjectSettings } from "@/lib/project/types"; import type { TProject, TProjectSettings } from "@/project/types";
export class UpdateProjectSettingsCommand extends Command { export class UpdateProjectSettingsCommand extends Command {
private savedSettings: TProjectSettings | null = null; private savedSettings: TProjectSettings | null = null;

View File

@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/timeline";
import { buildDefaultScene } from "@/lib/scenes"; import { buildDefaultScene } from "@/timeline/scenes";
export class CreateSceneCommand extends Command { export class CreateSceneCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;

View File

@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; import { canDeleteScene, getFallbackSceneAfterDelete } from "@/timeline/scenes";
export class DeleteSceneCommand extends Command { export class DeleteSceneCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;

View File

@ -1,8 +1,8 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/timeline/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
export class MoveBookmarkCommand extends Command { export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;

View File

@ -1,11 +1,11 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/timeline/scenes";
import { import {
getFrameTime, getFrameTime,
removeBookmarkFromArray, removeBookmarkFromArray,
} from "@/lib/timeline/bookmarks"; } from "@/timeline/bookmarks/index";
export class RemoveBookmarkCommand extends Command { export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;

View File

@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/timeline/scenes";
export class RenameSceneCommand extends Command { export class RenameSceneCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;

View File

@ -1,8 +1,11 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/timeline/scenes";
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks"; import {
getFrameTime,
toggleBookmarkInArray,
} from "@/timeline/bookmarks/index";
export class ToggleBookmarkCommand extends Command { export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;

View File

@ -1,8 +1,11 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { Bookmark, TScene } from "@/lib/timeline"; import type { Bookmark, TScene } from "@/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/timeline/scenes";
import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; import {
getFrameTime,
updateBookmarkInArray,
} from "@/timeline/bookmarks/index";
export class UpdateBookmarkCommand extends Command { export class UpdateBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;

View File

@ -4,11 +4,11 @@ import {
resolveAnimationTarget, resolveAnimationTarget,
updateScalarKeyframeCurve, updateScalarKeyframeCurve,
upsertPathKeyframe, upsertPathKeyframe,
} from "@/lib/animation"; } from "@/animation";
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import type { KeyframeClipboardItem } from "@/lib/clipboard"; import type { KeyframeClipboardItem } from "@/clipboard";
import type { SceneTracks, TimelineElement } from "@/lib/timeline"; import type { SceneTracks, TimelineElement } from "@/timeline";
import { updateElementInSceneTracks } from "@/lib/timeline"; import { updateElementInSceneTracks } from "@/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
function pasteKeyframesIntoElement({ function pasteKeyframesIntoElement({

View File

@ -0,0 +1,193 @@
import {
Command,
createElementSelectionResult,
type CommandResult,
} from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { SceneTracks, TimelineElement } from "@/timeline";
import type { ElementClipboardItem } from "@/clipboard";
import { generateUUID } from "@/utils/id";
import {
applyPlacement,
resolveTrackPlacement,
enforceMainTrackStart,
} from "@/timeline/placement";
import { cloneAnimations } from "@/animation";
export class PasteCommand extends Command {
private savedState: SceneTracks | null = null;
private pastedElements: { trackId: string; elementId: string }[] = [];
private readonly time: number;
private readonly clipboardItems: ElementClipboardItem[];
constructor({
time,
clipboardItems,
}: {
time: number;
clipboardItems: ElementClipboardItem[];
}) {
super();
this.time = time;
this.clipboardItems = clipboardItems;
}
execute(): CommandResult | undefined {
if (this.clipboardItems.length === 0) return undefined;
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
this.pastedElements = [];
const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime),
);
let updatedTracks = this.savedState;
const itemsByTrackId = groupClipboardItemsByTrackId({
clipboardItems: this.clipboardItems,
});
for (const [trackId, items] of itemsByTrackId) {
const elementsToAdd = buildPastedElements({
items,
minStart,
time: this.time,
});
if (elementsToAdd.length === 0) {
continue;
}
const trackType = items[0].trackType;
const sourceTrackIndex = [
...updatedTracks.overlay,
updatedTracks.main,
...updatedTracks.audio,
].findIndex((track) => track.id === trackId);
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType,
timeSpans: elementsToAdd.map((element) => ({
startTime: element.startTime,
duration: element.duration,
})),
strategy: { type: "aboveSource", sourceTrackIndex },
});
if (!placementResult) {
continue;
}
let elementsForPlacement = elementsToAdd;
if (placementResult.kind === "existingTrack") {
const targetTrack =
placementResult.trackIndex < updatedTracks.overlay.length
? updatedTracks.overlay[placementResult.trackIndex]
: placementResult.trackIndex === updatedTracks.overlay.length
? updatedTracks.main
: updatedTracks.audio[
placementResult.trackIndex - updatedTracks.overlay.length - 1
];
if (targetTrack?.id === updatedTracks.main.id) {
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
);
const adjustedEarliestStartTime = enforceMainTrackStart({
tracks: updatedTracks,
targetTrackId: targetTrack.id,
requestedStartTime: earliestElement.startTime,
});
const delta = adjustedEarliestStartTime - earliestElement.startTime;
if (delta !== 0) {
elementsForPlacement = elementsToAdd.map((element) => ({
...element,
startTime: Math.max(0, element.startTime + delta),
}));
}
}
}
const applied = applyPlacement({
tracks: updatedTracks,
placementResult,
elements: elementsForPlacement,
});
if (!applied) {
continue;
}
updatedTracks = applied.updatedTracks;
for (const element of elementsForPlacement) {
this.pastedElements.push({
trackId: applied.targetTrackId,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
if (this.pastedElements.length > 0) {
return createElementSelectionResult(this.pastedElements);
}
return undefined;
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getPastedElements(): { trackId: string; elementId: string }[] {
return this.pastedElements;
}
}
function groupClipboardItemsByTrackId({
clipboardItems,
}: {
clipboardItems: ElementClipboardItem[];
}): Map<string, ElementClipboardItem[]> {
const groupedItems = new Map<string, ElementClipboardItem[]>();
for (const item of clipboardItems) {
const existingItems = groupedItems.get(item.trackId) ?? [];
groupedItems.set(item.trackId, [...existingItems, item]);
}
return groupedItems;
}
function buildPastedElements({
items,
minStart,
time,
}: {
items: ElementClipboardItem[];
minStart: number;
time: number;
}): TimelineElement[] {
const elementsToAdd: TimelineElement[] = [];
for (const item of items) {
const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, time + relativeOffset);
const newElementId = generateUUID();
elementsToAdd.push({
...item.element,
id: newElementId,
startTime,
animations: cloneAnimations({
animations: item.element.animations,
shouldRegenerateKeyframeIds: true,
}),
} as TimelineElement);
}
return elementsToAdd;
}

View File

@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import type { SceneTracks } from "@/lib/timeline"; import type { SceneTracks } from "@/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/timeline";
function removeTrackElements<TTrack extends TimelineTrack>({ function removeTrackElements<TTrack extends TimelineTrack>({
track, track,

View File

@ -0,0 +1,134 @@
import {
Command,
createElementSelectionResult,
type CommandResult,
} from "@/commands/base-command";
import type { SceneTracks, TimelineElement } from "@/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
import { cloneAnimations } from "@/animation";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
}
export class DuplicateElementsCommand extends Command {
private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: SceneTracks | null = null;
private elements: DuplicateElementsParams["elements"];
constructor({ elements }: DuplicateElementsParams) {
super();
this.elements = elements;
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
this.duplicatedElements = [];
let updatedTracks = this.savedState;
for (const track of [
...this.savedState.overlay,
this.savedState.main,
...this.savedState.audio,
]) {
const elementsToDuplicate = this.elements.filter(
(elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
continue;
}
const elementIdsToDuplicate = new Set(
elementsToDuplicate.map((element) => element.elementId),
);
const newTrackElements: TimelineElement[] = [];
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
newTrackElements.push(
buildDuplicateElement({
element,
id: newId,
startTime: element.startTime,
}),
);
}
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType: track.type,
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
});
if (!placementResult || placementResult.kind !== "newTrack") {
continue;
}
const applied = applyPlacement({
tracks: updatedTracks,
placementResult,
elements: newTrackElements,
});
if (!applied) {
continue;
}
updatedTracks = applied.updatedTracks;
for (const element of newTrackElements) {
this.duplicatedElements.push({
trackId: applied.targetTrackId,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
if (this.duplicatedElements.length > 0) {
return createElementSelectionResult(this.duplicatedElements);
}
return undefined;
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getDuplicatedElements(): { trackId: string; elementId: string }[] {
return this.duplicatedElements;
}
}
function buildDuplicateElement({
element,
id,
startTime,
}: {
element: TimelineElement;
id: string;
startTime: number;
}): TimelineElement {
return {
...element,
id,
name: `${element.name} (copy)`,
startTime,
animations: cloneAnimations({
animations: element.animations,
shouldRegenerateKeyframeIds: true,
}),
};
}

View File

@ -1,8 +1,8 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
import type { SceneTracks, VisualElement } from "@/lib/timeline"; import type { SceneTracks, VisualElement } from "@/timeline";
import { buildDefaultEffectInstance } from "@/lib/effects"; import { buildDefaultEffectInstance } from "@/effects";
function addEffectToElement({ function addEffectToElement({
element, element,

Some files were not shown because too many files have changed in this diff Show More