Merge branch 'main' into feature/subtitles

# Conflicts:
#	apps/web/src/components/editor/panels/assets/views/captions.tsx
This commit is contained in:
kcfancher 2026-04-11 06:53:29 -04:00
commit 423f4acde6
237 changed files with 16290 additions and 10365 deletions

View File

@ -31,11 +31,6 @@ jobs:
MARBLE_WORKSPACE_KEY: "placeholder"
FREESOUND_CLIENT_ID: "placeholder"
FREESOUND_API_KEY: "placeholder"
CLOUDFLARE_ACCOUNT_ID: "placeholder"
R2_ACCESS_KEY_ID: "placeholder"
R2_SECRET_ACCESS_KEY: "placeholder"
R2_BUCKET_NAME: "placeholder"
MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
steps:
- name: Checkout repository

View File

@ -1,13 +1,19 @@
# Agents.md
## Apps
## Architecture
- Web
- Desktop
An ongoing migration is moving all business logic into `rust/`. Each app under `apps/` is a UI shell — it owns rendering, interaction, and platform-specific concerns, but never owns logic. The UI framework for any given app is a replaceable detail.
## Rust
### `rust/`
Shared code between apps live in `rust/`, not in `packages/`
The single source of truth for all non-UI code. Everything platform-agnostic belongs here: no components, no hooks, no framework imports.
### `apps/`
Each app is a frontend that calls into Rust. Logic is never duplicated between apps — only UI is, because each platform may use an entirely different framework and language to build it.
- `web/` — Next.js
- `desktop/` — GPUI
## Web

26
Cargo.lock generated
View File

@ -1427,6 +1427,16 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "effects"
version = "0.1.0"
dependencies = [
"bytemuck",
"gpu",
"thiserror 2.0.18",
"wgpu",
]
[[package]]
name = "either"
version = "1.15.0"
@ -3217,6 +3227,15 @@ dependencies = [
"libc",
]
[[package]]
name = "masks"
version = "0.1.0"
dependencies = [
"bytemuck",
"gpu",
"wgpu",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@ -3777,10 +3796,14 @@ dependencies = [
[[package]]
name = "opencut-wasm"
version = "0.1.2"
version = "0.2.3"
dependencies = [
"bridge",
"effects",
"gpu",
"js-sys",
"masks",
"num-traits",
"serde",
"serde-wasm-bindgen",
"time",
@ -5581,6 +5604,7 @@ name = "time"
version = "0.1.0"
dependencies = [
"bridge",
"num-traits",
"serde",
"tsify-next",
"wasm-bindgen",

View File

@ -4,5 +4,8 @@ members = [
"apps/desktop",
"rust/crates/time",
"rust/crates/bridge",
"rust/wasm", "rust/crates/gpu",
"rust/crates/effects",
"rust/crates/gpu",
"rust/crates/masks",
"rust/wasm",
]

View File

@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here
FREESOUND_CLIENT_ID=your_client_id_here
FREESOUND_API_KEY=your_api_key_here
CLOUDFLARE_ACCOUNT_ID=your_account_id_here
R2_ACCESS_KEY_ID=your_access_key_here
R2_SECRET_ACCESS_KEY=your_secret_key_here
R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
MODAL_TRANSCRIPTION_URL=your_modal_url_here

View File

@ -29,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token"
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY
ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder"
ENV R2_ACCESS_KEY_ID="build-placeholder"
ENV R2_SECRET_ACCESS_KEY="build-placeholder"
ENV R2_BUCKET_NAME="build-placeholder"
ENV MODAL_TRANSCRIPTION_URL="http://localhost:0"
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY

View File

@ -79,5 +79,9 @@ changes:
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
- type: new
text: "You can now import transcript files to generate captions instead of running auto-transcription."
- type: new
text: "Graph editor for keyframe curves. Shape the easing between keyframes by dragging bezier handles."
- type: fixed
text: "Text element handles no longer shift position as you type."
---

View File

@ -2,7 +2,6 @@ import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
import { webEnv } from "@/lib/env/web";
// Load the right env file based on environment
if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" });
} else {

View File

@ -52,7 +52,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"opencut-wasm": "^0.1.2",
"opencut-wasm": "^0.2.3",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",

View File

@ -21,7 +21,7 @@ import type {
TProjectSortKey,
TProjectSortOption,
} from "@/lib/project/types";
import { formatTimeCode } from "opencut-wasm";
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@ -76,8 +76,9 @@ const formatProjectDuration = ({
return null;
}
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
const durationSeconds = mediaTimeToSeconds({ time: duration });
const format = durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimecode({ time: duration, format }) ?? "";
};
const VIEW_MODE_OPTIONS = [

View File

@ -1,14 +1,14 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
time: number;
duration: number;
format?: TimeCodeFormat;
fps: number;
fps: FrameRate;
onTimeChange?: ({ time }: { time: number }) => void;
className?: string;
disabled?: boolean;
@ -28,7 +28,7 @@ export function EditableTimecode({
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const enterPressedRef = useRef(false);
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
const formattedTime = formatTimecode({ time, format, rate: fps }) ?? "";
const startEditing = () => {
if (disabled) return;
@ -46,17 +46,16 @@ export function EditableTimecode({
};
const applyEdit = () => {
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
if (parsedTime == null) {
setHasError(true);
return;
}
const clampedTime = Math.max(
0,
duration ? Math.min(duration, parsedTime) : parsedTime,
);
const clampedTime = duration
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
: parsedTime;
onTimeChange?.({ time: clampedTime });
setIsEditing(false);

View File

@ -8,7 +8,7 @@ import {
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "opencut-wasm";
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
import { Button } from "@/components/ui/button";
function InfoRow({
@ -35,9 +35,10 @@ export function ProjectInfoDialog({
onOpenChange: (open: boolean) => void;
project: TProjectMetadata;
}) {
const durationSeconds = mediaTimeToSeconds({ time: project.duration });
const durationFormatted =
project.duration > 0
? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
? (formatTimecode({ time: project.duration, format: durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
: "0:00";
return (

View File

@ -25,7 +25,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { invokeAction } from "@/lib/actions";
@ -257,7 +257,7 @@ function MediaAssetDraggable({
startTime: number;
}) => {
const duration =
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: asset.id,
mediaType: asset.type,

View File

@ -63,7 +63,7 @@ export function Captions() {
setProcessingStep("Extracting audio...");
const audioBlob = await extractTimelineAudio({
tracks: editor.timeline.getTracks(),
tracks: editor.scenes.getActiveScene().tracks,
mediaAssets: editor.media.getAssets(),
totalDuration: editor.timeline.getTotalDuration(),
});
@ -274,7 +274,7 @@ export function Captions() {
)}
{warnings.length > 0 && (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3">
<ul className="text-sm text-amber-700 space-y-1">
<ul className="space-y-1 text-sm text-amber-700">
{warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}

View File

@ -10,6 +10,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { FPS_PRESETS } from "@/lib/fps/constants";
import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
@ -232,12 +233,12 @@ export function SettingsView() {
<Section showTopBorder={false}>
<SectionHeader className="justify-between">
<SectionTitle className="flex-1">Frame rate</SectionTitle>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={(value) => {
const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } });
}}
<Select
value={String(Math.round(frameRateToFloat(activeProject.settings.fps)))}
onValueChange={(value) => {
const fps = floatToFrameRate(parseFloat(value));
editor.project.updateSettings({ settings: { fps } });
}}
>
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
<SelectValue placeholder="Select a frame rate" />

View File

@ -8,6 +8,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useEditor } from "@/hooks/use-editor";
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
import {
@ -70,41 +71,25 @@ export function StickersView() {
/>
</div>
<div className="mt-2 flex min-h-0 flex-1 flex-col">
<div className="border-b border-border px-2">
<div
role="tablist"
aria-label="Sticker categories"
className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
>
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
const isActive = key === selectedCategory;
return (
<button
key={key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() =>
setSelectedCategory({
category: key as StickerCategory,
})
}
className={cn(
"text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
isActive && "text-primary border-primary",
)}
>
{label}
</button>
);
})}
</div>
</div>
<Tabs
value={selectedCategory}
onValueChange={(value) => {
setSelectedCategory({ category: value as StickerCategory });
}}
variant="underline"
className="mt-2 flex min-h-0 flex-1 flex-col"
>
<TabsList aria-label="Sticker categories">
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
<TabsTrigger key={key} value={key}>
{label}
</TabsTrigger>
))}
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
<StickersContentView />
</div>
</div>
</Tabs>
</div>
);
}

View File

@ -7,6 +7,7 @@ import { useRafLoop } from "@/hooks/use-raf-loop";
import { useContainerSize } from "@/hooks/use-container-size";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
@ -72,7 +73,9 @@ export function PreviewPanel() {
function RenderTreeController() {
const editor = useEditor();
const tracks = useEditor((e) => e.timeline.getRenderTracks());
const tracks = useEditor(
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
);
const mediaAssets = useEditor((e) => e.media.getAssets());
const activeProject = useEditor((e) => e.project.getActive());
@ -129,12 +132,16 @@ function PreviewCanvas({
height: nativeHeight,
fps: activeProject.settings.fps,
});
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
}, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]);
const render = useCallback(() => {
if (canvasRef.current && renderTree && !renderingRef.current) {
const renderTime = editor.playback.getCurrentTime();
const frame = Math.floor(renderTime * renderer.fps);
const renderTime = Math.min(
editor.playback.getCurrentTime(),
editor.timeline.getLastFrameTime(),
);
const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator);
const frame = Math.floor(renderTime / ticksPerFrame);
if (
frame !== lastFrameRef.current ||

View File

@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "opencut-wasm";
import { formatTimecode } from "opencut-wasm";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
@ -93,7 +93,7 @@ function TimecodeDisplay() {
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
{formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
</span>
</div>
);

View File

@ -1,24 +1,22 @@
import { useEditor } from "@/hooks/use-editor";
import { getElementLocalTime } from "@/lib/animation";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
export function useElementPlayhead({
startTime,
duration,
}: {
startTime: number;
duration: number;
}) {
const editor = useEditor();
const playheadTime = editor.playback.getCurrentTime();
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: startTime,
elementDuration: duration,
});
const isPlayheadWithinElementRange =
playheadTime >= startTime - TIME_EPSILON_SECONDS &&
playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
return { localTime, isPlayheadWithinElementRange };
}
import { useEditor } from "@/hooks/use-editor";
import { getElementLocalTime } from "@/lib/animation";
export function useElementPlayhead({
startTime,
duration,
}: {
startTime: number;
duration: number;
}) {
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: startTime,
elementDuration: duration,
});
const isPlayheadWithinElementRange =
playheadTime >= startTime &&
playheadTime <= startTime + duration;
return { localTime, isPlayheadWithinElementRange };
}

View File

@ -21,6 +21,7 @@ export function useKeyframedNumberProperty({
valueAtPlayhead,
step,
buildBaseUpdates,
buildAdditionalKeyframes,
}: {
trackId: string;
elementId: string;
@ -33,6 +34,9 @@ export function useKeyframedNumberProperty({
valueAtPlayhead: number;
step?: number;
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
buildAdditionalKeyframes?: ({
value,
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
}) {
const editor = useEditor();
const snapValue = (value: number) =>
@ -50,19 +54,26 @@ export function useKeyframedNumberProperty({
const previewValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
const updatedAnimations = [
{ propertyPath, value: nextValue },
...additionalKeyframes,
].reduce(
(currentAnimations, keyframe) =>
upsertElementKeyframe({
animations: currentAnimations,
propertyPath: keyframe.propertyPath,
time: localTime,
value: keyframe.value,
}),
animations,
);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertElementKeyframe({
animations,
propertyPath,
time: localTime,
value: nextValue,
}),
},
updates: { animations: updatedAnimations },
},
],
});
@ -125,15 +136,17 @@ export function useKeyframedNumberProperty({
const commitValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
editor.timeline.upsertKeyframes({
keyframes: [
{
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
...additionalKeyframes.map((keyframe) => ({
trackId,
elementId,
propertyPath,
propertyPath: keyframe.propertyPath,
time: localTime,
value: nextValue,
},
value: keyframe.value,
})),
],
});
return;
@ -144,7 +157,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
updates: buildBaseUpdates({ value: nextValue }),
patch: buildBaseUpdates({ value: nextValue }),
},
],
});

View File

@ -1,148 +1,150 @@
"use client";
import { useEditor } from "@/hooks/use-editor";
import {
buildGraphicParamPath,
getKeyframeAtTime,
getParamDefaultInterpolation,
getParamValueKind,
hasKeyframesForPath,
upsertPathKeyframe,
} from "@/lib/animation";
import type {
ElementAnimations,
} from "@/lib/animation/types";
import type { ParamDefinition } from "@/lib/params";
import type { TimelineElement } from "@/lib/timeline";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
isKeyframedAtTime: boolean;
keyframeIdAtTime: string | null;
onPreview: (value: number | string | boolean) => void;
onCommit: () => void;
toggleKeyframe: () => void;
}
export function useKeyframedParamProperty({
param,
trackId,
elementId,
animations,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
buildBaseUpdates,
}: {
param: ParamDefinition;
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
buildBaseUpdates: ({
value,
}: {
value: number | string | boolean;
}) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult {
const editor = useEditor();
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
const hasAnimatedKeyframes = hasKeyframesForPath({
animations,
propertyPath,
});
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({
animations,
propertyPath,
time: localTime,
})
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertPathKeyframe({
animations,
propertyPath,
time: localTime,
value,
valueKind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({
param,
}),
numericRange:
param.type === "number"
? { min: param.min, max: param.max, step: param.step }
: undefined,
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value }),
},
],
});
};
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
keyframeId: keyframeIdAtTime,
},
],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value: resolvedValue,
},
],
});
};
return {
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
onPreview: previewValue,
onCommit: () => editor.timeline.commitPreview(),
toggleKeyframe,
};
}
"use client";
import { useEditor } from "@/hooks/use-editor";
import {
buildGraphicParamPath,
coerceAnimationValueForParam,
getKeyframeAtTime,
getParamDefaultInterpolation,
getParamValueKind,
hasKeyframesForPath,
upsertPathKeyframe,
} from "@/lib/animation";
import type {
ElementAnimations,
} from "@/lib/animation/types";
import type { ParamDefinition } from "@/lib/params";
import type { TimelineElement } from "@/lib/timeline";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
isKeyframedAtTime: boolean;
keyframeIdAtTime: string | null;
onPreview: (value: number | string | boolean) => void;
onCommit: () => void;
toggleKeyframe: () => void;
}
export function useKeyframedParamProperty({
param,
trackId,
elementId,
animations,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
buildBaseUpdates,
}: {
param: ParamDefinition;
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
buildBaseUpdates: ({
value,
}: {
value: number | string | boolean;
}) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult {
const editor = useEditor();
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
const hasAnimatedKeyframes = hasKeyframesForPath({
animations,
propertyPath,
});
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({
animations,
propertyPath,
time: localTime,
})
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertPathKeyframe({
animations,
propertyPath,
time: localTime,
value,
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({
param,
}),
coerceValue: ({ value: nextValue }) =>
coerceAnimationValueForParam({
param,
value: nextValue,
}),
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value }),
},
],
});
};
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
keyframeId: keyframeIdAtTime,
},
],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value: resolvedValue,
},
],
});
};
return {
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
onPreview: previewValue,
onCommit: () => editor.timeline.commitPreview(),
toggleKeyframe,
};
}

View File

@ -152,7 +152,7 @@ export function useKeyframedVectorProperty({
}
editor.timeline.updateElements({
updates: [
{ trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
],
});
};
@ -172,7 +172,7 @@ export function useKeyframedVectorProperty({
}
editor.timeline.updateElements({
updates: [
{ trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
],
});
};

View File

@ -17,7 +17,7 @@ import { EmptyView } from "./empty-view";
export function PropertiesPanel() {
const editor = useEditor();
useEditor((e) => e.timeline.getTracks());
useEditor((e) => e.scenes.getActiveSceneOrNull());
useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
const { activeTabPerType, setActiveTab } = usePropertiesStore();

View File

@ -41,7 +41,7 @@ type BlendingElement = {
animations?: ElementAnimations;
};
const BLEND_MODE_GROUPS = [
const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
[{ value: "normal", label: "Normal" }],
[
{ value: "darken", label: "Darken" },
@ -99,7 +99,7 @@ export function BlendingTab({
],
});
const commitBlendMode = (value: string) => {
const commitBlendMode = (value: BlendMode) => {
if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview();
} else {
@ -108,7 +108,7 @@ export function BlendingTab({
{
trackId,
elementId: element.id,
updates: { blendMode: value as BlendMode },
patch: { blendMode: value },
},
],
});
@ -209,7 +209,7 @@ export function BlendingTab({
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode({ value: option.value as BlendMode })
previewBlendMode({ value: option.value })
}
>
{option.label}

View File

@ -123,7 +123,7 @@ function StrokeSection({
{
trackId,
elementId: element.id,
updates: { params: { ...element.params, strokeWidth: 0 } },
patch: { params: { ...element.params, strokeWidth: 0 } },
},
],
});
@ -133,7 +133,7 @@ function StrokeSection({
{
trackId,
elementId: element.id,
updates: {
patch: {
params: {
...element.params,
strokeWidth: lastStrokeWidth.current,

View File

@ -15,7 +15,6 @@ import {
PlusSignIcon,
RotateClockwiseIcon,
} from "@hugeicons/core-free-icons";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
@ -88,7 +87,9 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
fallback: element,
});
const maskDefs = masksRegistry.getAll();
const tracks = useEditor((e) => e.timeline.getRenderTracks());
const tracks = useEditor(
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
);
const currentTime = useEditor((e) => e.playback.getCurrentTime());
const mediaAssets = useEditor((e) => e.media.getAssets());
const canvasSize = useEditor(
@ -102,7 +103,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
const elementBounds = useMemo(() => {
const clampedTime = Math.min(
Math.max(currentTime, element.startTime),
element.startTime + element.duration - TIME_EPSILON_SECONDS,
element.startTime + element.duration - 1,
);
return (
@ -165,7 +166,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
{
trackId,
elementId: element.id,
updates: {
patch: {
masks: [
buildDefaultMaskInstance({
maskType,

View File

@ -165,7 +165,7 @@ function TypographySection({
{
trackId,
elementId: element.id,
updates: { fontFamily: value },
patch: { fontFamily: value },
},
],
})
@ -188,7 +188,7 @@ function TypographySection({
{
trackId,
elementId: element.id,
updates: {
patch: {
fontSize: DEFAULTS.text.element.fontSize,
},
},
@ -291,7 +291,7 @@ function SpacingSection({
{
trackId,
elementId: element.id,
updates: { letterSpacing: DEFAULTS.text.letterSpacing },
patch: { letterSpacing: DEFAULTS.text.letterSpacing },
},
],
})
@ -317,7 +317,7 @@ function SpacingSection({
{
trackId,
elementId: element.id,
updates: { lineHeight: DEFAULTS.text.lineHeight },
patch: { lineHeight: DEFAULTS.text.lineHeight },
},
],
})
@ -512,7 +512,7 @@ function BackgroundSection({
{
trackId,
elementId: element.id,
updates: {
patch: {
background: {
...element.background,
enabled,

View File

@ -120,6 +120,9 @@ export function TransformTab({
...(isScaleLocked ? { scaleY: value } : {}),
},
}),
buildAdditionalKeyframes: isScaleLocked
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
: undefined,
});
const scaleY = useKeyframedNumberProperty({
@ -140,6 +143,9 @@ export function TransformTab({
...(isScaleLocked ? { scaleX: value } : {}),
},
}),
buildAdditionalKeyframes: isScaleLocked
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
: undefined,
});
const scaleFieldPropsX = {

View File

@ -4,13 +4,12 @@ import { useEffect, useState } from "react";
import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import {
DEFAULT_TIMELINE_BOOKMARK_COLOR,
} from "./theme";
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/lib/fps/constants";
import { getSnappedSeekTime } from "opencut-wasm";
import { snappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
@ -49,8 +48,8 @@ function seekToBookmarkTime({
}) {
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
editor.playback.seek({ time: snappedTime });
}
@ -139,7 +138,7 @@ function TimelineBookmark({
const isDragging =
dragState.isDragging &&
dragState.bookmarkTime !== null &&
Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
dragState.bookmarkTime === bookmark.time;
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
const time = bookmark.time;

View File

@ -105,6 +105,8 @@ export function computeDropTarget({
excludeElementId,
targetElementTypes,
}: ComputeDropTargetParams): DropTarget {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const mainTrackIndex = tracks.overlay.length;
const xPosition =
typeof startTimeOverride === "number"
? startTimeOverride
@ -112,7 +114,7 @@ export function computeDropTarget({
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
if (tracks.length === 0) {
if (orderedTracks.length === 0) {
const placementResult = resolveTrackPlacement({
tracks,
elementType,
@ -139,7 +141,11 @@ export function computeDropTarget({
};
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
const trackAtMouse = getTrackAtY({
mouseY,
tracks: orderedTracks,
verticalDragDirection,
});
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
@ -150,7 +156,7 @@ export function computeDropTarget({
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1,
hoverDirection: isAboveAllTracks ? "above" : "below",
createNewTrackOnly: true,
},
@ -171,12 +177,12 @@ export function computeDropTarget({
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
const track = orderedTracks[trackIndex];
if (targetElementTypes && targetElementTypes.length > 0) {
const targetElement = findElementAtPosition({
mouseX,
tracks,
tracks: orderedTracks,
trackIndex,
targetElementTypes,
pixelsPerSecond,

View File

@ -0,0 +1,235 @@
"use client";
import { useRef, useState, type PointerEvent } from "react";
import { useShiftKey } from "@/hooks/use-shift-key";
import { getBezierPoint } from "@/lib/animation/bezier";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
import { cn } from "@/utils/ui";
const GRAPH_WIDTH = 140;
const GRAPH_HEIGHT = 94;
const GRAPH_PADDING = 12;
const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
const HANDLE_RADIUS = 3.5;
const ENDPOINT_RADIUS = 2;
const SNAP_THRESHOLD = 0.06;
const SNAP_TARGETS = [0, 1];
const CURVE_SEGMENTS = 64;
const Y_CLAMP_MIN = -0.5;
const Y_CLAMP_MAX = 1.5;
type BezierHandle = "c1" | "c2";
export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
function snap({
value,
targets,
isEnabled,
}: {
value: number;
targets: number[];
isEnabled: boolean;
}) {
if (!isEnabled) return value;
for (const target of targets) {
if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
}
return value;
}
function toSvgX({ value }: { value: number }) {
return GRAPH_PADDING + value * GRAPH_WIDTH;
}
function toSvgY({ value }: { value: number }) {
return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
}
function fromSvgX({ svgX }: { svgX: number }) {
return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
}
function fromSvgY({ svgY }: { svgY: number }) {
return Math.max(
Y_CLAMP_MIN,
Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
);
}
function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
const points: string[] = [];
for (let i = 0; i <= CURVE_SEGMENTS; i++) {
const progress = i / CURVE_SEGMENTS;
const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) });
const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) });
points.push(`${x},${y}`);
}
return `M${points.join("L")}`;
}
function clampHandleY({ svgY }: { svgY: number }) {
return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
}
export function BezierGraph({
value,
onChange,
onChangeEnd,
onCancel,
}: {
value: NormalizedCubicBezier;
onChange?: (value: NormalizedCubicBezier) => void;
onChangeEnd?: (value: NormalizedCubicBezier) => void;
onCancel?: () => void;
}) {
const svgRef = useRef<SVGSVGElement>(null);
const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
const isShiftPressedRef = useShiftKey();
const latestValueRef = useRef(value);
latestValueRef.current = value;
function getPointerPosition({
event,
}: {
event: PointerEvent;
}): { x: number; y: number } {
const svg = svgRef.current;
if (!svg) return { x: 0, y: 0 };
const rect = svg.getBoundingClientRect();
const scale = SVG_WIDTH / rect.width;
return {
x: (event.clientX - rect.left) * scale,
y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
};
}
function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
return (event: PointerEvent<SVGCircleElement>) => {
event.preventDefault();
event.stopPropagation();
setActiveHandle(handle);
event.currentTarget.setPointerCapture(event.pointerId);
};
}
function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
if (!activeHandle) return;
const pointerPos = getPointerPosition({ event });
const x = fromSvgX({ svgX: pointerPos.x });
const y = snap({
value: fromSvgY({ svgY: pointerPos.y }),
targets: SNAP_TARGETS,
isEnabled: !isShiftPressedRef.current,
});
const next: NormalizedCubicBezier = [...value];
if (activeHandle === "c1") {
next[0] = x;
next[1] = y;
} else {
next[2] = x;
next[3] = y;
}
latestValueRef.current = next;
onChange?.(next);
}
function onPointerUp() {
if (!activeHandle) return;
setActiveHandle(null);
onChangeEnd?.(latestValueRef.current);
}
function onPointerCancel() {
if (!activeHandle) return;
setActiveHandle(null);
onCancel?.();
}
const path = curvePath({ curve: value });
const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
return (
<svg
ref={svgRef}
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
className="bg-foreground/3 w-full cursor-crosshair select-none"
onPointerMove={(event) => onPointerMove({ event })}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
>
<title>Bezier curve editor</title>
<line
x1={p0.x}
y1={p0.y}
x2={p1.x}
y2={p1.y}
className="stroke-foreground/8"
strokeWidth={1}
strokeDasharray="3 3"
/>
<line
x1={p0.x}
y1={p0.y}
x2={c1Clamped.x}
y2={c1Clamped.y}
className="stroke-primary/30"
strokeWidth={1}
/>
<line
x1={p1.x}
y1={p1.y}
x2={c2Clamped.x}
y2={c2Clamped.y}
className="stroke-primary/30"
strokeWidth={1}
/>
<path
d={path}
fill="none"
className="stroke-primary"
strokeWidth={2}
strokeLinecap="round"
/>
<circle
cx={p0.x}
cy={p0.y}
r={ENDPOINT_RADIUS}
className="fill-foreground/20"
/>
<circle
cx={p1.x}
cy={p1.y}
r={ENDPOINT_RADIUS}
className="fill-foreground/20"
/>
<circle
cx={c1Clamped.x}
cy={c1Clamped.y}
r={HANDLE_RADIUS}
className={cn(
"fill-primary cursor-grab",
activeHandle === "c1" && "cursor-grabbing",
)}
onPointerDown={onHandlePointerDown({ handle: "c1" })}
/>
<circle
cx={c2Clamped.x}
cy={c2Clamped.y}
r={HANDLE_RADIUS}
className={cn(
"fill-primary cursor-grab",
activeHandle === "c2" && "cursor-grabbing",
)}
onPointerDown={onHandlePointerDown({ handle: "c2" })}
/>
</svg>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import { useSyncExternalStore } from "react";
import { generateUUID } from "@/utils/id";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
import type { EasingPreset } from "./easing-presets";
const STORAGE_KEY = "opencut:graph-editor-presets";
let cachedPresets: EasingPreset[] | null = null;
const listeners = new Set<() => void>();
function isValidPresetArray(value: unknown): value is EasingPreset[] {
return (
Array.isArray(value) &&
value.every(
(item) =>
typeof item === "object" &&
item !== null &&
typeof item.id === "string" &&
typeof item.label === "string" &&
Array.isArray(item.value) &&
item.value.length === 4 &&
item.value.every((number: unknown) => typeof number === "number"),
)
);
}
function readFromStorage(): EasingPreset[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
return isValidPresetArray(parsed) ? parsed : [];
} catch {
// Silently recover — corrupted localStorage shouldn't crash the editor
return [];
}
}
function writeToStorage({ presets }: { presets: EasingPreset[] }): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
}
function getSnapshot(): EasingPreset[] {
cachedPresets ??= readFromStorage();
return cachedPresets;
}
function getServerSnapshot(): EasingPreset[] {
return [];
}
function notify(): void {
cachedPresets = null;
for (const listener of listeners) {
listener();
}
}
function onStorageChange(event: StorageEvent): void {
if (event.key === STORAGE_KEY) notify();
}
function subscribe(listener: () => void): () => void {
if (listeners.size === 0 && typeof window !== "undefined") {
window.addEventListener("storage", onStorageChange);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0 && typeof window !== "undefined") {
window.removeEventListener("storage", onStorageChange);
}
};
}
export function useCustomPresets(): EasingPreset[] {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function savePreset({ value }: { value: NormalizedCubicBezier }): void {
const current = getSnapshot();
writeToStorage({
presets: [
...current,
{
id: generateUUID(),
label: `Custom ${current.length + 1}`,
value,
isCustom: true,
},
],
});
notify();
}
export function removePreset({ id }: { id: string }): void {
writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) });
notify();
}

View File

@ -0,0 +1,19 @@
import type { NormalizedCubicBezier } from "@/lib/animation/types";
export const PRESET_MATCH_TOLERANCE = 0.02;
export interface EasingPreset {
id: string;
label: string;
value: NormalizedCubicBezier;
isCustom?: boolean;
}
export const BUILTIN_PRESETS: EasingPreset[] = [
{ id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
{ id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
{ id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
{ id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
{ id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
{ id: "linear", label: "Linear", value: [0, 0, 1, 1] },
];

View File

@ -0,0 +1,334 @@
"use client";
import { useState } from "react";
import { Popover, PopoverContent } from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowDown01Icon,
Delete02Icon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { getBezierPoint } from "@/lib/animation/bezier";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
import type { GraphEditorComponentOption } from "./session";
import {
BUILTIN_PRESETS,
PRESET_MATCH_TOLERANCE,
type EasingPreset,
} from "./easing-presets";
import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store";
import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
const COLLAPSED_MAX = 6;
const THUMB_SEGMENTS = 24;
const THUMB_WIDTH = 40;
const THUMB_HEIGHT = 22;
const THUMB_PADDING_X = 4;
const THUMB_PADDING_Y = 3;
const COLLAPSED_GRID_MAX_HEIGHT = 120;
const EXPANDED_GRID_MAX_HEIGHT = 240;
export function GraphEditorPopover({
children,
side,
open,
onOpenChange,
value,
message,
componentOptions,
activeComponentKey,
onActiveComponentKeyChange,
onPreviewValue,
onCommitValue,
onCancelPreview,
}: {
children: React.ReactNode;
side?: "top" | "bottom" | "left" | "right";
open?: boolean;
onOpenChange?: (open: boolean) => void;
value: NormalizedCubicBezier | null;
message: string;
componentOptions: GraphEditorComponentOption[];
activeComponentKey: string | null;
onActiveComponentKeyChange?: (componentKey: string) => void;
onPreviewValue?: (value: NormalizedCubicBezier) => void;
onCommitValue?: (value: NormalizedCubicBezier) => void;
onCancelPreview?: () => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const custom = useCustomPresets();
const allPresets = [...BUILTIN_PRESETS, ...custom];
const canEdit = value !== null;
const activePresetId =
value == null
? null
: (allPresets.find((preset) =>
preset.value.every(
(presetValue, index) =>
Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE,
),
)?.id ?? null);
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onCancelPreview?.();
}
onOpenChange?.(nextOpen);
}}
>
{children}
<PopoverContent
side={side}
sideOffset={8}
className="w-60 overflow-hidden px-0"
>
{componentOptions.length > 1 && (
<div className="border-b px-3 py-2">
<div className="bg-muted/40 inline-flex rounded-md p-0.5">
{componentOptions.map((component) => (
<button
key={component.key}
type="button"
onClick={() => onActiveComponentKeyChange?.(component.key)}
className={cn(
"cursor-pointer rounded-sm px-2 py-1 text-xs font-medium",
activeComponentKey === component.key
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{component.label}
</button>
))}
</div>
</div>
)}
<div className="px-3 py-3">
{value ? (
<BezierGraph
value={value}
onChange={onPreviewValue}
onChangeEnd={onCommitValue}
onCancel={onCancelPreview}
/>
) : (
<GraphEditorEmptyState message={message} />
)}
</div>
<Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2">
<TabsList className="px-3">
<TabsTrigger value="presets" className="text-xs">
Presets
</TabsTrigger>
<TabsTrigger value="saved" className="text-xs">
Saved
</TabsTrigger>
</TabsList>
<TabsContent value="presets" className="px-3 pb-0">
<ExpandableGrid
isExpanded={isExpanded}
shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX}
onExpand={() => setIsExpanded(true)}
>
{BUILTIN_PRESETS.map((preset) => (
<PresetItem
key={preset.id}
preset={preset}
isActive={activePresetId === preset.id}
disabled={!canEdit}
onSelect={() => onCommitValue?.(preset.value)}
/>
))}
</ExpandableGrid>
</TabsContent>
<TabsContent value="saved" className="px-3">
<div className="grid grid-cols-3 gap-1">
{custom.map((preset) => (
<PresetItem
key={preset.id}
preset={preset}
isActive={activePresetId === preset.id}
disabled={!canEdit}
onSelect={() => onCommitValue?.(preset.value)}
onDelete={() => removePreset({ id: preset.id })}
/>
))}
<button
type="button"
onClick={() => value && savePreset({ value })}
disabled={!canEdit}
className={cn(
"text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
canEdit
? "hover:bg-foreground/5 cursor-pointer"
: "cursor-not-allowed opacity-50",
)}
>
<div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed">
<HugeiconsIcon
icon={PlusSignIcon}
className="size-3.5 opacity-40"
/>
</div>
<span className="text-[10px] leading-tight">Save</span>
</button>
</div>
</TabsContent>
</Tabs>
</PopoverContent>
</Popover>
);
}
function GraphEditorEmptyState({ message }: { message: string }) {
return (
<div
style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }}
className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed"
>
{message}
</div>
);
}
function ExpandableGrid({
children,
isExpanded,
shouldExpand,
onExpand,
}: {
children: React.ReactNode;
isExpanded: boolean;
shouldExpand: boolean;
onExpand: () => void;
}) {
const gridStyle = shouldExpand
? isExpanded
? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const }
: { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const }
: undefined;
return (
<div className="relative">
<div className="grid grid-cols-3 gap-1" style={gridStyle}>
{children}
</div>
{!isExpanded && shouldExpand && (
<div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b">
<Button
variant="ghost"
size="icon"
className="size-5"
onClick={onExpand}
>
<HugeiconsIcon
icon={ArrowDown01Icon}
className="text-muted-foreground size-3"
/>
</Button>
</div>
)}
</div>
);
}
function PresetItem({
preset,
isActive,
onSelect,
onDelete,
disabled,
}: {
preset: EasingPreset;
isActive: boolean;
onSelect: () => void;
onDelete?: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onSelect}
disabled={disabled}
className={cn(
"group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1",
disabled
? "cursor-not-allowed opacity-50"
: "hover:bg-foreground/5 cursor-pointer",
isActive && "bg-primary/5! text-primary",
)}
>
<div
className={cn(
"flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5",
isActive && "bg-primary/5!",
)}
>
<CurveThumb value={preset.value} />
</div>
<span
className={cn(
"text-[10px] leading-tight",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
{preset.label}
</span>
{onDelete && (
<Button
variant="destructive"
size="icon"
className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
>
<HugeiconsIcon icon={Delete02Icon} />
</Button>
)}
</button>
);
}
function toThumbX({ value }: { value: number }) {
return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2);
}
function toThumbY({ value }: { value: number }) {
return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2);
}
function CurveThumb({ value }: { value: NormalizedCubicBezier }) {
const points: string[] = [];
for (let i = 0; i <= THUMB_SEGMENTS; i++) {
const progress = i / THUMB_SEGMENTS;
const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) });
const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) });
points.push(`${x},${y}`);
}
return (
<svg
width={THUMB_WIDTH}
height={THUMB_HEIGHT}
viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
>
<title>Curve preset preview</title>
<path
d={`M${points.join("L")}`}
fill="none"
className="stroke-current"
strokeWidth={1.5}
strokeLinecap="round"
/>
</svg>
);
}

View File

@ -0,0 +1,446 @@
import {
getCurveHandlesForNormalizedCubicBezier,
getEditableScalarChannels,
getNormalizedCubicBezierForScalarSegment,
getScalarKeyframeContext,
updateScalarKeyframeCurve,
} from "@/lib/animation";
import type {
AnimationPath,
ElementAnimations,
NormalizedCubicBezier,
ScalarCurveKeyframePatch,
ScalarGraphKeyframeContext,
SelectedKeyframeRef,
} from "@/lib/animation/types";
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
const FLAT_VALUE_EPSILON = 1e-6;
const LINEAR_CURVE_EPSILON = 1e-6;
export type GraphEditorUnavailableReason =
| "no-keyframe-selected"
| "multiple-keyframes-selected"
| "selected-element-missing"
| "selected-element-has-no-animations"
| "selected-keyframe-has-no-scalar-channel"
| "selected-keyframe-missing-on-channel"
| "selected-keyframe-has-no-next-segment"
| "selected-segment-is-hold"
| "selected-segment-is-flat";
export interface GraphEditorComponentOption {
key: string;
label: string;
}
interface GraphEditorBaseSelectionState {
componentOptions: GraphEditorComponentOption[];
activeComponentKey: string | null;
message: string;
}
export interface GraphEditorUnavailableState
extends GraphEditorBaseSelectionState {
status: "unavailable";
reason: GraphEditorUnavailableReason;
}
export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
status: "ready";
trackId: string;
elementId: string;
propertyPath: SelectedKeyframeRef["propertyPath"];
keyframeId: string;
element: TimelineElement;
context: ScalarGraphKeyframeContext;
cubicBezier: NormalizedCubicBezier;
}
export type GraphEditorSelectionState =
| GraphEditorUnavailableState
| GraphEditorReadyState;
export interface GraphEditorCurvePatch {
keyframeId: string;
patch: ScalarCurveKeyframePatch;
}
function createUnavailableState({
reason,
message,
componentOptions = [],
activeComponentKey = null,
}: {
reason: GraphEditorUnavailableReason;
message: string;
componentOptions?: GraphEditorComponentOption[];
activeComponentKey?: string | null;
}): GraphEditorUnavailableState {
return {
status: "unavailable",
reason,
message,
componentOptions,
activeComponentKey,
};
}
function findElementByKeyframe({
tracks,
keyframe,
}: {
tracks: SceneTracks;
keyframe: SelectedKeyframeRef;
}): { element: TimelineElement; trackId: string; elementId: string } | null {
for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
if (track.id !== keyframe.trackId) {
continue;
}
const element = track.elements.find(
(trackElement) => trackElement.id === keyframe.elementId,
);
if (!element) {
return null;
}
return {
element,
trackId: track.id,
elementId: element.id,
};
}
return null;
}
function findKeyframeTime({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations;
propertyPath: AnimationPath;
keyframeId: string;
}): number | null {
const binding = animations.bindings[propertyPath];
if (!binding) return null;
for (const component of binding.components) {
const channel = animations.channels[component.channelId];
if (channel?.kind !== "scalar") continue;
const key = channel.keys.find((k) => k.id === keyframeId);
if (key !== undefined) return key.time;
}
return null;
}
function getComponentLabel({ componentKey }: { componentKey: string }): string {
switch (componentKey) {
case "value":
return "Value";
default:
return componentKey.toUpperCase();
}
}
function isFlatSegment({
context,
}: {
context: ScalarGraphKeyframeContext;
}): boolean {
if (!context.nextKey) {
return true;
}
return (
Math.abs(context.nextKey.value - context.keyframe.value) <=
FLAT_VALUE_EPSILON
);
}
function isLinearCurve({
cubicBezier,
}: {
cubicBezier: NormalizedCubicBezier;
}): boolean {
return (
Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
);
}
export function resolveGraphEditorSelectionState({
tracks,
selectedKeyframes,
preferredComponentKey,
}: {
tracks: SceneTracks;
selectedKeyframes: SelectedKeyframeRef[];
preferredComponentKey?: string | null;
}): GraphEditorSelectionState {
if (selectedKeyframes.length === 0) {
return createUnavailableState({
reason: "no-keyframe-selected",
message: "Select a keyframe to edit its curve.",
});
}
if (selectedKeyframes.length > 2) {
return createUnavailableState({
reason: "multiple-keyframes-selected",
message: "Select one or two adjacent keyframes to edit a curve.",
});
}
if (selectedKeyframes.length === 2) {
const [firstKeyframe, secondKeyframe] = selectedKeyframes;
if (
firstKeyframe.trackId !== secondKeyframe.trackId ||
firstKeyframe.elementId !== secondKeyframe.elementId ||
firstKeyframe.propertyPath !== secondKeyframe.propertyPath
) {
return createUnavailableState({
reason: "multiple-keyframes-selected",
message: "Selected keyframes must be on the same element and property.",
});
}
}
const primaryKeyframe = selectedKeyframes[0];
const secondaryKeyframeId =
selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null;
const selectedElement = findElementByKeyframe({
tracks,
keyframe: primaryKeyframe,
});
if (!selectedElement) {
return createUnavailableState({
reason: "selected-element-missing",
message: "The selected keyframe could not be resolved.",
});
}
if (!selectedElement.element.animations) {
return createUnavailableState({
reason: "selected-element-has-no-animations",
message: "The selected keyframe has no editable graph.",
});
}
const scalarChannels = getEditableScalarChannels({
animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath,
});
if (scalarChannels.length === 0) {
return createUnavailableState({
reason: "selected-keyframe-has-no-scalar-channel",
message: "The selected keyframe has no editable graph channel.",
});
}
// When 2 keyframes are selected, resolve the earlier one as the outgoing-segment
// anchor so the graph editor edits the curve between the two selected keyframes.
let resolvedKeyframeId = primaryKeyframe.keyframeId;
if (secondaryKeyframeId !== null) {
const time1 = findKeyframeTime({
animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath,
keyframeId: primaryKeyframe.keyframeId,
});
const time2 = findKeyframeTime({
animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath,
keyframeId: secondaryKeyframeId,
});
if (time2 !== null && (time1 === null || time2 < time1)) {
resolvedKeyframeId = secondaryKeyframeId;
}
}
const contexts = scalarChannels.flatMap((channel) => {
const context = getScalarKeyframeContext({
animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath,
componentKey: channel.componentKey,
keyframeId: resolvedKeyframeId,
});
if (!context) {
return [];
}
return [
{
context,
option: {
key: channel.componentKey,
label: getComponentLabel({ componentKey: channel.componentKey }),
},
},
];
});
if (contexts.length === 0) {
return createUnavailableState({
reason: "selected-keyframe-missing-on-channel",
message: "The selected keyframe is not editable as a graph segment.",
});
}
const nextSegmentContexts = contexts.filter(
({ context }) => context.nextKey !== null,
);
const preferredContext =
contexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
const activeContext =
preferredContext ?? nextSegmentContexts[0] ?? contexts[0];
const componentOptions = contexts.map(({ option }) => option);
if (!activeContext.context.nextKey) {
return createUnavailableState({
reason: "selected-keyframe-has-no-next-segment",
message: "Select a keyframe that has an outgoing segment.",
componentOptions,
activeComponentKey: activeContext.option.key,
});
}
if (isFlatSegment({ context: activeContext.context })) {
return createUnavailableState({
reason: "selected-segment-is-flat",
message: "Flat segments are not graph-editable in this popover yet.",
componentOptions,
activeComponentKey: activeContext.option.key,
});
}
if (activeContext.context.keyframe.segmentToNext === "step") {
return createUnavailableState({
reason: "selected-segment-is-hold",
message: "Hold segments are not graph-editable in this popover yet.",
componentOptions,
activeComponentKey: activeContext.option.key,
});
}
const cubicBezier =
activeContext.context.keyframe.segmentToNext === "linear"
? GRAPH_LINEAR_CURVE
: getNormalizedCubicBezierForScalarSegment({
leftKey: activeContext.context.keyframe,
rightKey: activeContext.context.nextKey,
});
if (!cubicBezier) {
return createUnavailableState({
reason: "selected-segment-is-flat",
message: "The selected segment cannot be represented in this graph view.",
componentOptions,
activeComponentKey: activeContext.option.key,
});
}
return {
status: "ready",
message: "Edit graph",
componentOptions,
activeComponentKey: activeContext.option.key,
trackId: selectedElement.trackId,
elementId: selectedElement.elementId,
propertyPath: primaryKeyframe.propertyPath,
keyframeId: resolvedKeyframeId,
element: selectedElement.element,
context: activeContext.context,
cubicBezier,
};
}
export function buildGraphEditorCurvePatches({
context,
cubicBezier,
}: {
context: ScalarGraphKeyframeContext;
cubicBezier: NormalizedCubicBezier;
}): GraphEditorCurvePatch[] | null {
if (!context.nextKey) {
return null;
}
if (isLinearCurve({ cubicBezier })) {
return [
{
keyframeId: context.keyframe.id,
patch: {
segmentToNext: "linear",
rightHandle: null,
},
},
{
keyframeId: context.nextKey.id,
patch: {
leftHandle: null,
},
},
];
}
const handles = getCurveHandlesForNormalizedCubicBezier({
leftKey: context.keyframe,
rightKey: context.nextKey,
cubicBezier,
});
if (!handles) {
return null;
}
return [
{
keyframeId: context.keyframe.id,
patch: {
segmentToNext: "bezier",
rightHandle: handles.rightHandle,
},
},
{
keyframeId: context.nextKey.id,
patch: {
leftHandle: handles.leftHandle,
},
},
];
}
export function applyGraphEditorCurvePreview({
animations,
context,
cubicBezier,
}: {
animations: ElementAnimations | undefined;
context: ScalarGraphKeyframeContext;
cubicBezier: NormalizedCubicBezier;
}): ElementAnimations | undefined {
const patches = buildGraphEditorCurvePatches({
context,
cubicBezier,
});
if (!patches) {
return animations;
}
return patches.reduce<ElementAnimations | undefined>(
(nextAnimations, { keyframeId, patch }) =>
updateScalarKeyframeCurve({
animations: nextAnimations,
propertyPath: context.propertyPath,
componentKey: context.componentKey,
keyframeId,
patch,
}),
animations,
);
}

View File

@ -0,0 +1,160 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { registerCanceller } from "@/lib/cancel-interaction";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
import {
applyGraphEditorCurvePreview,
buildGraphEditorCurvePatches,
resolveGraphEditorSelectionState,
type GraphEditorSelectionState,
} from "./session";
export function useGraphEditorController() {
const editor = useEditor();
const renderTracks = useEditor((currentEditor) =>
currentEditor.timeline.getPreviewTracks() ??
currentEditor.scenes.getActiveScene().tracks,
);
const { selectedKeyframes } = useKeyframeSelection();
const [open, setOpen] = useState(false);
const [activeComponentKey, setActiveComponentKey] = useState<string | null>(
null,
);
const hasPreviewRef = useRef(false);
const state = useMemo<GraphEditorSelectionState>(
() =>
resolveGraphEditorSelectionState({
tracks: renderTracks,
selectedKeyframes,
preferredComponentKey: activeComponentKey,
}),
[activeComponentKey, renderTracks, selectedKeyframes],
);
const stateKey =
state.status === "ready"
? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}`
: `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
const previousStateKeyRef = useRef(stateKey);
const discardPreview = useCallback(() => {
if (!hasPreviewRef.current) {
return;
}
editor.timeline.discardPreview();
hasPreviewRef.current = false;
}, [editor]);
useEffect(() => {
if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) {
discardPreview();
}
previousStateKeyRef.current = stateKey;
}, [discardPreview, stateKey]);
useEffect(() => {
if (!open) {
return;
}
return registerCanceller({
fn: () => {
discardPreview();
setOpen(false);
},
});
}, [discardPreview, open]);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
discardPreview();
}
setOpen(nextOpen);
},
[discardPreview],
);
const handleActiveComponentKeyChange = useCallback(
(nextComponentKey: string) => {
discardPreview();
setActiveComponentKey(nextComponentKey);
},
[discardPreview],
);
const handlePreviewValue = useCallback(
(nextValue: NormalizedCubicBezier) => {
if (state.status !== "ready") {
return;
}
const nextAnimations = applyGraphEditorCurvePreview({
animations: state.element.animations,
context: state.context,
cubicBezier: nextValue,
});
editor.timeline.previewElements({
updates: [
{
trackId: state.trackId,
elementId: state.elementId,
updates: {
animations: nextAnimations,
},
},
],
});
hasPreviewRef.current = true;
},
[editor, state],
);
const handleCommitValue = useCallback(
(nextValue: NormalizedCubicBezier) => {
if (state.status !== "ready") {
return;
}
const patches = buildGraphEditorCurvePatches({
context: state.context,
cubicBezier: nextValue,
});
if (!patches) {
return;
}
editor.timeline.updateKeyframeCurves({
keyframes: patches.map(({ keyframeId, patch }) => ({
trackId: state.trackId,
elementId: state.elementId,
propertyPath: state.propertyPath,
componentKey: state.context.componentKey,
keyframeId,
patch,
})),
});
hasPreviewRef.current = false;
},
[editor, state],
);
return {
open,
onOpenChange: handleOpenChange,
canOpen: state.status === "ready",
tooltip: state.status === "ready" ? "Open graph editor" : state.message,
state,
onActiveComponentKeyChange: handleActiveComponentKeyChange,
onPreviewValue: handlePreviewValue,
onCommitValue: handleCommitValue,
onCancelPreview: discardPreview,
};
}

View File

@ -50,7 +50,7 @@ import {
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import {
getTrackHeight,
getCumulativeHeightBefore,
@ -58,7 +58,6 @@ import {
} from "./track-layout";
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
import { isMainTrack } from "@/lib/timeline/placement";
import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
@ -112,7 +111,15 @@ export function Timeline() {
} = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
const tracks = useEditor((editor) => editor.timeline.getTracks());
const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull());
const tracks = useMemo<TimelineTrack[]>(
() =>
scene
? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
: [],
[scene],
);
const mainTrackId = scene?.tracks.main.id ?? null;
const seek = (time: number) => editor.playback.seek({ time });
const timelineRef = useRef<HTMLDivElement>(null);
@ -340,8 +347,7 @@ export function Timeline() {
const containerWidth =
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
timelineDuration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel });
const paddingPx = getTimelinePaddingPx({
containerWidth,
zoomLevel,
@ -425,13 +431,13 @@ export function Timeline() {
/>
<DragLine
dropTarget={dropTarget}
tracks={timeline.getTracks()}
tracks={tracks}
isVisible={isDragOver && !dropTarget?.targetElement}
headerHeight={timelineHeaderHeight}
/>
<DragLine
dropTarget={dragDropTarget}
tracks={timeline.getTracks()}
tracks={tracks}
isVisible={dragState.isDragging}
headerHeight={timelineHeaderHeight}
/>
@ -509,6 +515,7 @@ export function Timeline() {
{tracks.length > 0 && (
<TimelineTrackRows
dragElementId={dragState.elementId}
mainTrackId={mainTrackId}
zoomLevel={zoomLevel}
dragState={dragState}
tracksScrollRef={tracksScrollRef}
@ -575,7 +582,14 @@ function TrackLabelsPanel({
hasHorizontalScrollbar: boolean;
}) {
const editor = useEditor();
const tracks = useEditor((e) => e.timeline.getTracks());
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
const tracks = useMemo<TimelineTrack[]>(
() =>
scene
? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
: [],
[scene],
);
const { selectedElements } = useElementSelection();
const tracksWithSelection = useMemo(
() => new Set(selectedElements.map((el) => el.trackId)),
@ -651,6 +665,7 @@ function TrackLabelsPanel({
function TimelineTrackRows({
dragElementId,
mainTrackId,
zoomLevel,
dragState,
tracksScrollRef,
@ -666,6 +681,7 @@ function TimelineTrackRows({
dropTarget,
}: {
dragElementId: string | null;
mainTrackId: string | null;
zoomLevel: number;
dragState: ElementDragState;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
@ -685,7 +701,14 @@ function TimelineTrackRows({
dropTarget: DropTarget | null;
}) {
const timeline = useEditor((e) => e.timeline);
const tracks = useEditor((e) => e.timeline.getTracks());
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
const tracks = useMemo<TimelineTrack[]>(
() =>
scene
? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
: [],
[scene],
);
const { selectedElements } = useElementSelection();
const tracksWithSelection = useMemo(
() => new Set(selectedElements.map((el) => el.trackId)),
@ -780,7 +803,7 @@ function TimelineTrackRows({
? "Show track"
: "Hide track"}
</ContextMenuItem>
{!isMainTrack(track) && (
{track.id !== mainTrackId && (
<ContextMenuItem
icon={<HugeiconsIcon icon={Delete02Icon} />}
onClick={(event: React.MouseEvent) => {

View File

@ -1,5 +1,5 @@
import type { TimelineTrack } from "@/lib/timeline";
import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils";
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import {
TIMELINE_CONTENT_TOP_PADDING_PX,
} from "./layout";
@ -96,7 +96,6 @@ export function resolveTimelineElementIntersections({
startPos,
endPos: currentPos,
});
const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
const selectedElements: TimelineElementRef[] = [];
for (const [trackIndex, track] of tracks.entries()) {
@ -109,8 +108,8 @@ export function resolveTimelineElementIntersections({
const elementBottom = elementTop + trackHeight;
for (const element of track.elements) {
const elementLeft = element.startTime * pixelsPerSecond;
const elementRight = elementLeft + element.duration * pixelsPerSecond;
const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel });
const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel });
const elementRectangle = {
left: elementLeft,
top: elementTop,

View File

@ -292,10 +292,7 @@ export function TimelineElement({
const canToggleCurrentSourceAudio =
selectedElements.length === 1 &&
isCurrentElementSelected &&
canToggleSourceAudio({
element,
mediaAsset,
});
canToggleSourceAudio(element, mediaAsset);
const sourceAudioLabel =
element.type === "video"
? getSourceAudioActionLabel({ element })

View File

@ -7,6 +7,7 @@ import {
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEditor } from "@/hooks/use-editor";
import {
TIMELINE_SCROLLBAR_SIZE_PX,
@ -72,10 +73,11 @@ export function TimelinePlayhead({
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const step = 1 / Math.max(1, editor.project.getActive().settings.fps);
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
const direction = event.key === "ArrowRight" ? 1 : -1;
const now = editor.playback.getCurrentTime();
const nextTime = Math.max(0, Math.min(duration, now + direction * step));
const nextTime = Math.max(0, Math.min(duration, now + direction * ticksPerFrame));
editor.playback.seek({ time: nextTime });
};

View File

@ -2,6 +2,8 @@ import { type JSX, useLayoutEffect, useRef } from "react";
import {
BASE_TIMELINE_PIXELS_PER_SECOND,
} from "@/lib/timeline/scale";
import { mediaTimeToSeconds } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/lib/fps/constants";
import { useEditor } from "@/hooks/use-editor";
@ -30,17 +32,18 @@ export function TimelineRuler({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineRulerProps) {
const duration = useEditor((e) => e.timeline.getTotalDuration());
const durationTicks = useEditor((e) => e.timeline.getTotalDuration());
const durationSeconds = mediaTimeToSeconds({ time: durationTicks });
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration);
const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDurationSeconds = Math.max(durationSeconds, visibleDurationSeconds);
const fps =
useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
zoomLevel,
fps,
});
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
const tickCount = Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1;
const { scrollLeft, viewportWidth } = useScrollPosition({
scrollRef: tracksScrollRef,
@ -61,20 +64,20 @@ export function TimelineRuler({
prevZoomRef.current = zoomLevel;
}, [zoomLevel]);
const visibleStartTime = Math.max(
const visibleStartTimeSeconds = Math.max(
0,
(scrollLeft - bufferPx) / pixelsPerSecond,
);
const visibleEndTime =
const visibleEndTimeSeconds =
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
const startTickIndex = Math.max(
0,
Math.floor(visibleStartTime / tickIntervalSeconds),
Math.floor(visibleStartTimeSeconds / tickIntervalSeconds),
);
const endTickIndex = Math.min(
tickCount - 1,
Math.ceil(visibleEndTime / tickIntervalSeconds),
Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds),
);
const timelineTicks: Array<JSX.Element> = [];
@ -83,14 +86,16 @@ export function TimelineRuler({
tickIndex <= endTickIndex;
tickIndex += 1
) {
const time = tickIndex * tickIntervalSeconds;
if (time > effectiveDuration) break;
const timeSeconds = tickIndex * tickIntervalSeconds;
if (timeSeconds > effectiveDurationSeconds) break;
const showLabel = shouldShowLabel({ time, labelIntervalSeconds });
const timeTicks = Math.round(timeSeconds * TICKS_PER_SECOND);
const showLabel = shouldShowLabel({ time: timeSeconds, labelIntervalSeconds });
timelineTicks.push(
<TimelineTick
key={tickIndex}
time={time}
time={timeTicks}
timeInSeconds={timeSeconds}
zoomLevel={zoomLevel}
fps={fps}
showLabel={showLabel}
@ -104,7 +109,7 @@ export function TimelineRuler({
tabIndex={0}
aria-label="Timeline ruler"
aria-valuemin={0}
aria-valuemax={effectiveDuration}
aria-valuemax={effectiveDurationSeconds}
aria-valuenow={0}
className="relative flex-1 overflow-x-visible"
style={{ height: TIMELINE_RULER_HEIGHT_PX }}

View File

@ -1,39 +1,42 @@
"use client";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
interface TimelineTickProps {
time: number;
zoomLevel: number;
fps: number;
showLabel: boolean;
}
export function TimelineTick({
time,
zoomLevel,
fps,
showLabel,
}: TimelineTickProps) {
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
if (showLabel) {
const label = formatRulerLabel({ timeInSeconds: time, fps });
return (
<span
className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
style={{ left: `${leftPosition}px` }}
>
{label}
</span>
);
}
return (
<div
className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
style={{ left: `${leftPosition}px` }}
/>
);
}
"use client";
import type { FrameRate } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
interface TimelineTickProps {
time: number;
timeInSeconds: number;
zoomLevel: number;
fps: FrameRate;
showLabel: boolean;
}
export function TimelineTick({
time,
timeInSeconds,
zoomLevel,
fps,
showLabel,
}: TimelineTickProps) {
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
if (showLabel) {
const label = formatRulerLabel({ timeInSeconds, fps });
return (
<span
className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
style={{ left: `${leftPosition}px` }}
>
{label}
</span>
);
}
return (
<div
className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
style={{ left: `${leftPosition}px` }}
/>
);
}

View File

@ -14,9 +14,7 @@ import {
SplitButtonSeparator,
} from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider";
import {
TIMELINE_ZOOM_BUTTON_FACTOR,
} from "./interaction";
import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { ScenesView } from "@/components/editor/scenes-view";
@ -36,7 +34,6 @@ import {
SnowIcon,
ScissorIcon,
MagnetIcon,
Link04Icon,
SearchAddIcon,
SearchMinusIcon,
Copy01Icon,
@ -49,6 +46,9 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons";
import { GraphEditorPopover } from "./graph-editor/popover";
import { PopoverTrigger } from "@/components/ui/popover";
import { useGraphEditorController } from "./graph-editor/use-controller";
export function TimelineToolbar({
zoomLevel,
@ -62,10 +62,7 @@ export function TimelineToolbar({
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
const newZoomLevel =
direction === "in"
? Math.min(
TIMELINE_ZOOM_MAX,
zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR,
)
? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
setZoomLevel({ zoom: newZoomLevel });
};
@ -90,8 +87,11 @@ export function TimelineToolbar({
function ToolbarLeftSection() {
const editor = useEditor();
const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets());
const mediaAssets = useEditor((currentEditor) =>
currentEditor.media.getAssets(),
);
const { selectedElements } = useElementSelection();
const graphEditor = useGraphEditorController();
const isCurrentlyBookmarked = useEditor((e) =>
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
);
@ -115,10 +115,7 @@ function ToolbarLeftSection() {
})();
const canToggleSelectedSourceAudio =
!!selectedElement &&
canToggleSourceAudio({
element: selectedElement.element,
mediaAsset: selectedMediaAsset,
});
canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
const sourceAudioLabel =
selectedElement?.element.type === "video"
? getSourceAudioActionLabel({
@ -166,11 +163,11 @@ function ToolbarLeftSection() {
/>
<ToolbarButton
icon={
<HugeiconsIcon
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/>
}
icon={
<HugeiconsIcon
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/>
}
tooltip={sourceAudioLabel}
disabled={!canToggleSelectedSourceAudio}
onClick={({ event }) =>
@ -214,13 +211,35 @@ function ToolbarLeftSection() {
/>
</Tooltip>
<Tooltip>
<GraphEditorPopover
open={graphEditor.open}
onOpenChange={graphEditor.onOpenChange}
value={
graphEditor.state.status === "ready"
? graphEditor.state.cubicBezier
: null
}
message={graphEditor.state.message}
componentOptions={graphEditor.state.componentOptions}
activeComponentKey={graphEditor.state.activeComponentKey}
onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange}
onPreviewValue={graphEditor.onPreviewValue}
onCommitValue={graphEditor.onCommitValue}
onCancelPreview={graphEditor.onCancelPreview}
>
<ToolbarButton
icon={<HugeiconsIcon icon={Chart03Icon} />}
tooltip="Open graph editor"
onClick={() => {}}
tooltip={graphEditor.tooltip}
disabled={!graphEditor.canOpen}
buttonWrapper={(button) =>
graphEditor.canOpen ? (
<PopoverTrigger asChild>{button}</PopoverTrigger>
) : (
button
)
}
/>
</Tooltip>
</GraphEditorPopover>
</TooltipProvider>
</div>
);
@ -317,29 +336,40 @@ function ToolbarButton({
onClick,
disabled,
isActive,
buttonWrapper,
}: {
icon: React.ReactNode;
tooltip: string;
onClick: ({ event }: { event: React.MouseEvent }) => void;
onClick?: ({ event }: { event: React.MouseEvent }) => void;
disabled?: boolean;
isActive?: boolean;
buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
}) {
const button = (
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
disabled={disabled}
onClick={onClick ? (event) => onClick({ event }) : undefined}
className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
{icon}
</Button>
);
const trigger = disabled ? (
<span className="inline-flex">{button}</span>
) : buttonWrapper ? (
buttonWrapper(button)
) : (
button
);
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
disabled={disabled}
onClick={(event) => onClick({ event })}
className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);

View File

@ -7,6 +7,7 @@ import { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import { useKeybindingsListener } from "@/hooks/use-keybindings";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
@ -117,6 +118,13 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
function EditorRuntimeBindings() {
const editor = useEditor();
const rippleEditingEnabled = useTimelineStore(
(state) => state.rippleEditingEnabled,
);
useEffect(() => {
editor.command.isRippleEnabled = rippleEditingEnabled;
}, [editor, rippleEditingEnabled]);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {

View File

@ -36,7 +36,7 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const contextMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-foreground/85 outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0",
{
variants: {
variant: {
@ -94,7 +94,7 @@ const ContextMenuSubContent = React.forwardRef<
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-md border shadow-xl p-1",
className,
)}
{...props}
@ -112,7 +112,7 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5",
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-md border shadow-xl p-1",
className,
)}
{...props}
@ -155,7 +155,7 @@ const ContextMenuItem = React.forwardRef<
{...props}
>
{icon && (
<span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0">
<span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">
{icon}
</span>
)}
@ -242,7 +242,7 @@ const ContextMenuLabel = React.forwardRef<
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"flex items-center gap-2.5 px-4 py-1.5 text-sm font-semibold text-foreground",
"flex items-center gap-2 px-3 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className,
)}
@ -262,7 +262,7 @@ const ContextMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("bg-border my-2 h-px", className)}
className={cn("bg-border mx-1 my-1.5 h-px", className)}
{...props}
/>
));

View File

@ -1,293 +1,293 @@
"use client";
import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { useOverlayOpenChange } from "./use-overlay-open-change";
function DropdownMenu({
open,
onOpenChange,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
const handleOpenChange = useOverlayOpenChange({
source: "dropdown-menu",
open,
onOpenChange,
});
return (
<DropdownMenuPrimitive.Root
open={open}
onOpenChange={handleOpenChange}
{...props}
/>
);
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const dropdownMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "",
destructive:
"text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(({ className, inset, children, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
dropdownMenuItemVariants({ variant }),
"data-[state=open]:bg-muted data-[state=open]:text-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
onCloseAutoFocus={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className={cn(
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
icon?: React.ReactNode;
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(
(
{
className,
inset,
icon,
variant = "default",
children,
asChild,
...props
},
ref,
) => {
const iconSlot = (
<span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex">
{icon}
</span>
);
const renderedChildren =
asChild && React.isValidElement(children) ? (
React.cloneElement(
children as React.ReactElement<{ children?: React.ReactNode }>,
{},
iconSlot,
(children as React.ReactElement<{ children?: React.ReactNode }>).props
.children,
)
) : (
<>
{iconSlot}
{children}
</>
);
return (
<DropdownMenuPrimitive.Item
ref={ref}
asChild={asChild}
data-has-icon={icon ? "" : undefined}
className={cn(
dropdownMenuItemVariants({ variant }),
inset && "pl-8",
className,
)}
{...props}
>
{renderedChildren}
</DropdownMenuPrimitive.Item>
);
},
);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(({ className, children, checked, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
dropdownMenuItemVariants({ variant }),
"pr-8 pl-2",
className,
)}
checked={checked}
onSelect={(e) => {
e.preventDefault();
}}
{...props}
>
{children}
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(({ className, children, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
dropdownMenuItemVariants({ variant }),
"pr-2 pl-8",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("bg-border/60 mx-1 my-2 h-px", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
"use client";
import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { useOverlayOpenChange } from "./use-overlay-open-change";
function DropdownMenu({
open,
onOpenChange,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
const handleOpenChange = useOverlayOpenChange({
source: "dropdown-menu",
open,
onOpenChange,
});
return (
<DropdownMenuPrimitive.Root
open={open}
onOpenChange={handleOpenChange}
{...props}
/>
);
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const dropdownMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2.5 py-1.5 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "",
destructive:
"text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(({ className, inset, children, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
dropdownMenuItemVariants({ variant }),
"data-[state=open]:bg-muted data-[state=open]:text-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
onCloseAutoFocus={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className={cn(
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
icon?: React.ReactNode;
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(
(
{
className,
inset,
icon,
variant = "default",
children,
asChild,
...props
},
ref,
) => {
const iconSlot = (
<span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex">
{icon}
</span>
);
const renderedChildren =
asChild && React.isValidElement(children) ? (
React.cloneElement(
children as React.ReactElement<{ children?: React.ReactNode }>,
{},
iconSlot,
(children as React.ReactElement<{ children?: React.ReactNode }>).props
.children,
)
) : (
<>
{iconSlot}
{children}
</>
);
return (
<DropdownMenuPrimitive.Item
ref={ref}
asChild={asChild}
data-has-icon={icon ? "" : undefined}
className={cn(
dropdownMenuItemVariants({ variant }),
inset && "pl-8",
className,
)}
{...props}
>
{renderedChildren}
</DropdownMenuPrimitive.Item>
);
},
);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(({ className, children, checked, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
dropdownMenuItemVariants({ variant }),
"pr-8 pl-2",
className,
)}
checked={checked}
onSelect={(e) => {
e.preventDefault();
}}
{...props}
>
{children}
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(({ className, children, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
dropdownMenuItemVariants({ variant }),
"pr-2 pl-8",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("bg-border mx-1 my-1.5 h-px", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent",
"ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
variant === "default" &&
"data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
"border border-transparent data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
variant === "underline" &&
"text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
"text-muted-foreground data-[state=active]:text-primary border-x-0 border-t-0 border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
className,
)}
{...props}
@ -72,7 +72,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
variant === "underline" && "px-4",
className,
)}

View File

@ -1,2 +1 @@
export const TIME_EPSILON_SECONDS = 1 / 1000;
export const MIN_TRANSFORM_SCALE = 0.01;
export const MIN_TRANSFORM_SCALE = 0.01;

View File

@ -10,7 +10,6 @@ import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects";
import { registerDefaultMasks } from "@/lib/masks";
import { isMainTrack } from "@/lib/timeline/placement";
export class EditorCore {
private static instance: EditorCore | null = null;
@ -39,11 +38,21 @@ export class EditorCore {
this.audio = new AudioManager(this);
this.selection = new SelectionManager(this);
this.command.registerReactor(() => {
const tracks = this.timeline.getTracks();
const prunedTracks = tracks.filter(
(track) => track.elements.length > 0 || isMainTrack(track),
);
if (prunedTracks.length !== tracks.length) {
const activeScene = this.scenes.getActiveSceneOrNull();
if (!activeScene) {
return;
}
const tracks = activeScene.tracks;
const prunedTracks = {
...tracks,
overlay: tracks.overlay.filter((track) => track.elements.length > 0),
audio: tracks.audio.filter((track) => track.elements.length > 0),
};
if (
prunedTracks.overlay.length !== tracks.overlay.length ||
prunedTracks.audio.length !== tracks.audio.length
) {
this.timeline.updateTracks(prunedTracks);
}
});

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands";
import type { ElementRef } from "@/lib/timeline/types";
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
import type { ElementRef, SceneTracks } from "@/lib/timeline/types";
interface CommandHistoryEntry {
command: Command;
@ -9,6 +10,7 @@ interface CommandHistoryEntry {
}
export class CommandManager {
public isRippleEnabled = false;
private history: CommandHistoryEntry[] = [];
private redoStack: CommandHistoryEntry[] = [];
private reactors: Array<() => void> = [];
@ -16,8 +18,12 @@ export class CommandManager {
constructor(private editor: EditorCore) {}
execute({ command }: { command: Command }): Command {
const beforeTracks = this.isRippleEnabled
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
: null;
const previousSelection = this.getSelectionSnapshot();
const result = command.execute();
this.applyRippleIfEnabled({ beforeTracks });
const selectionOverride = this.applySelectionOverride(result);
this.runReactors();
this.history.push({
@ -67,8 +73,12 @@ export class CommandManager {
return;
}
const beforeTracks = this.isRippleEnabled
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
: null;
const previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo();
this.applyRippleIfEnabled({ beforeTracks });
const selectionOverride = this.applySelectionOverride(result);
this.runReactors();
@ -113,4 +123,32 @@ export class CommandManager {
reactor();
}
}
private applyRippleIfEnabled({
beforeTracks,
}: {
beforeTracks: SceneTracks | null;
}): void {
if (!this.isRippleEnabled || !beforeTracks) {
return;
}
const afterTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
if (!afterTracks) {
return;
}
const adjustments = computeRippleAdjustments({
beforeTracks,
afterTracks,
});
if (adjustments.length === 0) {
return;
}
const tracksWithRipple = applyRippleAdjustments({
tracks: afterTracks,
adjustments,
});
this.editor.timeline.updateTracks(tracksWithRipple);
}
}

View File

@ -1,4 +1,6 @@
import type { EditorCore } from "@/core";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { roundToFrame } from "opencut-wasm";
export class PlaybackManager {
private isPlaying = false;
@ -9,11 +11,12 @@ export class PlaybackManager {
private isScrubbing = false;
private listeners = new Set<() => void>();
private playbackTimer: number | null = null;
private lastUpdate = 0;
private playbackStartWallTime = 0;
private playbackStartTime = 0;
constructor(private editor: EditorCore) {
this.editor.timeline.subscribe(() => {
const maxTime = this.editor.timeline.getLastSeekableTime();
const maxTime = this.editor.timeline.getTotalDuration();
if (this.currentTime > maxTime && maxTime > 0) {
this.currentTime = maxTime;
this.notify();
@ -22,7 +25,7 @@ export class PlaybackManager {
}
play(): void {
const maxTime = this.editor.timeline.getLastSeekableTime();
const maxTime = this.editor.timeline.getTotalDuration();
if (maxTime > 0) {
if (this.currentTime >= maxTime) {
@ -50,8 +53,12 @@ export class PlaybackManager {
}
seek({ time }: { time: number }): void {
const maxTime = this.editor.timeline.getLastSeekableTime();
const maxTime = this.editor.timeline.getTotalDuration();
this.currentTime = Math.max(0, Math.min(maxTime, time));
if (this.isPlaying) {
this.playbackStartWallTime = performance.now();
this.playbackStartTime = this.currentTime;
}
this.notify();
window.dispatchEvent(
@ -135,7 +142,8 @@ export class PlaybackManager {
cancelAnimationFrame(this.playbackTimer);
}
this.lastUpdate = performance.now();
this.playbackStartWallTime = performance.now();
this.playbackStartTime = this.currentTime;
this.updateTime();
}
@ -149,12 +157,11 @@ export class PlaybackManager {
private updateTime = (): void => {
if (!this.isPlaying) return;
const now = performance.now();
const delta = (now - this.lastUpdate) / 1000;
this.lastUpdate = now;
const newTime = this.currentTime + delta;
const maxTime = this.editor.timeline.getLastSeekableTime();
const fps = this.editor.project.getActive()?.settings.fps;
const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000;
const rawTime = this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
const newTime = fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) : rawTime;
const maxTime = this.editor.timeline.getTotalDuration();
if (maxTime > 0 && newTime >= maxTime) {
this.pause();

View File

@ -156,9 +156,14 @@ export class ProjectManager {
await this.editor.media.loadProjectMedia({ projectId: id });
const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
await loadFonts({
families: getElementFontFamilies({ tracks: allTracks }),
families: [
...new Set(
(project.scenes ?? []).flatMap((scene) =>
getElementFontFamilies({ tracks: scene.tracks }),
),
),
],
});
if (!project.metadata.thumbnail) {
@ -496,7 +501,7 @@ export class ProjectManager {
importedAssets,
}: {
importedAssets: Array<Pick<MediaAsset, "type" | "fps">>;
}): number | null {
}): import("opencut-wasm").FrameRate | null {
if (!this.active) return null;
const nextFps = getRaisedProjectFpsForImportedMedia({
@ -643,7 +648,7 @@ export class ProjectManager {
private async updateThumbnailFromTimeline(): Promise<boolean> {
if (!this.active) return false;
const tracks = this.editor.timeline.getTracks();
const tracks = this.editor.scenes.getActiveScene().tracks;
const mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration();
const { canvasSize, background } = this.active.settings;

View File

@ -5,7 +5,8 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode } from "opencut-wasm";
import { formatTimecode } from "opencut-wasm";
import { frameRateToFloat } from "@/lib/fps/utils";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
@ -81,7 +82,10 @@ export class RendererManager {
}
const { canvasSize, fps } = activeProject.settings;
const renderTime = this.editor.playback.getCurrentTime();
const renderTime = Math.min(
this.editor.playback.getCurrentTime(),
this.editor.timeline.getLastFrameTime(),
);
const renderer = new CanvasRenderer({
width: canvasSize.width,
@ -107,7 +111,7 @@ export class RendererManager {
return { success: false, error: "Failed to create image" };
}
const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-");
const timecode = formatTimecode({ time: renderTime, rate: fps })!.replace(/:/g, "-");
const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot";
@ -135,7 +139,7 @@ export class RendererManager {
const { format, quality, fps, includeAudio } = options;
try {
const tracks = this.editor.timeline.getTracks();
const tracks = this.editor.scenes.getActiveScene().tracks;
const mediaAssets = this.editor.media.getAssets();
const activeProject = this.editor.project.getActive();
@ -148,7 +152,7 @@ export class RendererManager {
return { success: false, error: "Project is empty" };
}
const exportFps = fps || activeProject.settings.fps;
const exportFps = fps ?? activeProject.settings.fps;
const canvasSize = activeProject.settings.canvasSize;
let audioBuffer: AudioBuffer | null = null;

View File

@ -1,5 +1,5 @@
import type { EditorCore } from "@/core";
import type { TimelineTrack, TScene } from "@/lib/timeline";
import type { SceneTracks, TScene } from "@/lib/timeline";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
@ -12,7 +12,6 @@ import {
getFrameTime,
isBookmarkAtTime,
} from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/timeline/placement";
import {
CreateSceneCommand,
DeleteSceneCommand,
@ -174,10 +173,7 @@ 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 ensuredScenes = result.project.scenes ?? [];
const currentScene = findCurrentScene({
scenes: ensuredScenes,
currentSceneId: result.project.currentSceneId,
@ -186,22 +182,6 @@ export class ScenesManager {
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(),
},
};
this.editor.project.setActiveProject({ project: updatedProject });
this.editor.save.markDirty({ force: true });
}
}
}
} catch (error) {
console.error("Failed to load project scenes:", error);
@ -219,26 +199,24 @@ export class ScenesManager {
currentSceneId?: string;
}): void {
const ensuredScenes = ensureMainScene({ scenes });
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
const currentScene = currentSceneId
? scenesWithMainTracks.find((s) => s.id === currentSceneId)
? ensuredScenes.find((s) => s.id === currentSceneId)
: null;
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
const fallbackScene = getMainScene({ scenes: ensuredScenes });
this.list = scenesWithMainTracks;
this.list = ensuredScenes;
this.active = currentScene || fallbackScene;
this.notify();
const hasAddedMainScene = ensuredScenes.length > scenes.length;
if (hasAddedMainScene || hasAddedMainTrack) {
if (hasAddedMainScene) {
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: scenesWithMainTracks,
scenes: ensuredScenes,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
@ -264,6 +242,10 @@ export class ScenesManager {
return this.active;
}
getActiveSceneOrNull(): TScene | null {
return this.active;
}
getScenes(): TScene[] {
return this.list;
}
@ -307,7 +289,7 @@ export class ScenesManager {
});
}
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
updateSceneTracks({ tracks }: { tracks: SceneTracks }): void {
if (!this.active) return;
const updatedScene: TScene = {
@ -335,29 +317,4 @@ export class ScenesManager {
this.editor.project.setActiveProject({ project: updatedProject });
}
}
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 };
}
}

View File

@ -1,41 +1,47 @@
import type { EditorCore } from "@/core";
import type { ParamValues } from "@/lib/params";
import type {
SceneTracks,
TrackType,
TimelineTrack,
TimelineElement,
ClipboardItem,
RetimeConfig,
} from "@/lib/timeline";
import { calculateTotalDuration } from "@/lib/timeline";
import {
findTrackInSceneTracks,
updateElementInSceneTracks,
} from "@/lib/timeline/track-element-update";
import {
canElementBeHidden,
canElementHaveAudio,
} from "@/lib/timeline/element-utils";
import type {
AnimationPath,
AnimationInterpolation,
AnimationValue,
ScalarCurveKeyframePatch,
} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import { getLastFrameTime } from "opencut-wasm";
import { lastFrameTime } from "opencut-wasm";
import { BatchCommand } from "@/lib/commands";
import {
AddTrackCommand,
RemoveTrackCommand,
ToggleTrackMuteCommand,
ToggleTrackVisibilityCommand,
InsertElementCommand,
UpdateElementTrimCommand,
UpdateElementDurationCommand,
DeleteElementsCommand,
DuplicateElementsCommand,
ToggleElementsVisibilityCommand,
ToggleElementsMutedCommand,
UpdateElementCommand,
UpdateElementsCommand,
SplitElementsCommand,
PasteCommand,
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
UpdateElementRetimeCommand,
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand,
RemoveClipEffectCommand,
UpdateClipEffectParamsCommand,
@ -47,13 +53,12 @@ import {
RemoveEffectParamKeyframeCommand,
ToggleSourceAudioSeparationCommand,
} from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
private previewOverlay = new Map<string, Partial<TimelineElement>>();
private previewTracks: TimelineTrack[] | null = null;
private previewTracks: SceneTracks | null = null;
constructor(private editor: EditorCore) {}
@ -80,7 +85,6 @@ export class TimelineManager {
startTime,
duration,
pushHistory = true,
rippleEnabled = false,
}: {
elementId: string;
trimStart: number;
@ -88,44 +92,33 @@ export class TimelineManager {
startTime?: number;
duration?: number;
pushHistory?: boolean;
rippleEnabled?: boolean;
}): void {
const command = new UpdateElementTrimCommand({
elementId,
const trackId = this.findTrackIdForElement({ elementId });
if (!trackId) {
return;
}
const nextUpdates: Partial<TimelineElement> = {
trimStart,
trimEnd,
startTime,
duration,
rippleEnabled,
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
};
if (startTime !== undefined) {
nextUpdates.startTime = startTime;
}
if (duration !== undefined) {
nextUpdates.duration = duration;
}
}
updateElementDuration({
trackId,
elementId,
duration,
pushHistory = true,
}: {
trackId: string;
elementId: string;
duration: number;
pushHistory?: boolean;
}): void {
const command = new UpdateElementDurationCommand({
trackId,
elementId,
duration,
this.updateElements({
updates: [
{
trackId,
elementId,
patch: nextUpdates,
},
],
pushHistory,
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementRetime({
@ -139,30 +132,18 @@ export class TimelineManager {
retime?: RetimeConfig;
pushHistory?: boolean;
}): void {
const command = new UpdateElementRetimeCommand({
trackId,
elementId,
retime,
this.updateElements({
updates: [
{
trackId,
elementId,
patch: {
retime,
},
},
],
pushHistory,
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementStartTime({
elements,
startTime,
}: {
elements: { trackId: string; elementId: string }[];
startTime: number;
}): void {
const command = new UpdateElementStartTimeCommand({
elements,
startTime,
});
this.editor.command.execute({ command });
}
moveElement({
@ -171,14 +152,12 @@ export class TimelineManager {
elementId,
newStartTime,
createTrack,
rippleEnabled = false,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
rippleEnabled?: boolean;
}): void {
const command = new MoveElementCommand({
sourceTrackId,
@ -186,7 +165,6 @@ export class TimelineManager {
elementId,
newStartTime,
createTrack,
rippleEnabled,
});
this.editor.command.execute({ command });
}
@ -205,36 +183,43 @@ export class TimelineManager {
elements,
splitTime,
retainSide = "both",
rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
rippleEnabled?: boolean;
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand({
elements,
splitTime,
retainSide,
rippleEnabled,
});
this.editor.command.execute({ command });
return command.getRightSideElements();
}
getTotalDuration(): number {
return calculateTotalDuration({ tracks: this.getTracks() });
const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
return 0;
}
return calculateTotalDuration({ tracks: activeScene.tracks });
}
getLastSeekableTime(): number {
getLastFrameTime(): number {
const duration = this.getTotalDuration();
const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration;
return getLastFrameTime({ duration, fps });
return lastFrameTime({ duration, rate: fps }) ?? duration;
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
return this.getTracks().find((track) => track.id === trackId) ?? null;
const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
return null;
}
return findTrackInSceneTracks({ tracks: activeScene.tracks, trackId });
}
getElementsWithTracks({
@ -273,12 +258,10 @@ export class TimelineManager {
deleteElements({
elements,
rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
rippleEnabled?: boolean;
}): void {
const command = new DeleteElementsCommand({ elements, rippleEnabled });
const command = new DeleteElementsCommand({ elements });
this.editor.command.execute({ command });
}
@ -303,20 +286,17 @@ export class TimelineManager {
updates: Array<{
trackId: string;
elementId: string;
updates: Partial<TimelineElement>;
patch: Partial<TimelineElement>;
}>;
pushHistory?: boolean;
}): void {
const commands = updates.map(
({ trackId, elementId, updates: elementUpdates }) =>
new UpdateElementCommand({
trackId,
elementId,
updates: elementUpdates,
}),
);
const command =
commands.length === 1 ? commands[0] : new BatchCommand(commands);
if (updates.length === 0) {
return;
}
const command = new UpdateElementsCommand({
updates,
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
@ -549,6 +529,45 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
updateKeyframeCurves({
keyframes,
}: {
keyframes: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPath;
componentKey: string;
keyframeId: string;
patch: ScalarCurveKeyframePatch;
}>;
}): void {
if (keyframes.length === 0) {
return;
}
const commands = keyframes.map(
({
trackId,
elementId,
propertyPath,
componentKey,
keyframeId,
patch,
}) =>
new UpdateScalarKeyframeCurveCommand({
trackId,
elementId,
propertyPath,
componentKey,
keyframeId,
patch,
}),
);
const command =
commands.length === 1 ? commands[0] : new BatchCommand(commands);
this.editor.command.execute({ command });
}
upsertEffectParamKeyframe({
trackId,
elementId,
@ -625,14 +644,20 @@ export class TimelineManager {
} as Partial<TimelineElement>;
this.previewOverlay.set(elementId, mergedOverlay);
}
const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
if (!committedTracks) {
return;
}
this.previewTracks = this.applyPreviewOverlay(committedTracks);
this.notify();
}
commitPreview(): void {
if (this.previewOverlay.size === 0) return;
const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
if (!committedTracks) {
return;
}
const afterTracks =
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
const command = new TracksSnapshotCommand(committedTracks, afterTracks);
@ -649,19 +674,32 @@ export class TimelineManager {
this.notify();
}
private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] {
private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
if (this.previewOverlay.size === 0) return tracks;
return tracks.map((track) => {
const hasOverlay = track.elements.some((el) =>
this.previewOverlay.has(el.id),
const applyTrackOverlay = <TTrack extends TimelineTrack>(track: TTrack): TTrack => {
const hasOverlay = track.elements.some((element) =>
this.previewOverlay.has(element.id),
);
if (!hasOverlay) return track;
const newElements = track.elements.map((el) => {
const overlay = this.previewOverlay.get(el.id);
return overlay ? ({ ...el, ...overlay } as TimelineElement) : el;
if (!hasOverlay) {
return track;
}
const nextElements = track.elements.map((element) => {
const overlay = this.previewOverlay.get(element.id);
return overlay
? ({ ...element, ...overlay } as TimelineElement)
: element;
});
return { ...track, elements: newElements } as TimelineTrack;
});
return { ...track, elements: nextElements } as TTrack;
};
return {
overlay: tracks.overlay.map((track) => applyTrackOverlay(track)),
main: applyTrackOverlay(tracks.main),
audio: tracks.audio.map((track) => applyTrackOverlay(track)),
};
}
duplicateElements({
@ -679,8 +717,27 @@ export class TimelineManager {
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsVisibilityCommand(elements);
this.editor.command.execute({ command });
const shouldHide = elements.some(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
return element && canElementBeHidden(element) && !element.hidden;
});
const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
if (!element || !canElementBeHidden(element)) {
return [];
}
return [
{
trackId,
elementId,
patch: { hidden: shouldHide },
},
];
});
this.updateElements({ updates: nextUpdates });
}
toggleElementsMuted({
@ -688,17 +745,31 @@ export class TimelineManager {
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsMutedCommand(elements);
this.editor.command.execute({ command });
const shouldMute = elements.some(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
return element && canElementHaveAudio(element) && !element.muted;
});
const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
if (!element || !canElementHaveAudio(element)) {
return [];
}
return [
{
trackId,
elementId,
patch: { muted: shouldMute },
},
];
});
this.updateElements({ updates: nextUpdates });
}
getTracks(): TimelineTrack[] {
return this.editor.scenes.getActiveScene()?.tracks ?? [];
}
getRenderTracks(): TimelineTrack[] {
if (this.previewTracks !== null) return this.previewTracks;
return this.getTracks();
getPreviewTracks(): SceneTracks | null {
return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null;
}
subscribe(listener: () => void): () => void {
@ -712,7 +783,52 @@ export class TimelineManager {
});
}
updateTracks(newTracks: TimelineTrack[]): void {
private getElementByRef({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): TimelineElement | undefined {
return this.getTrackById({ trackId })?.elements.find(
(element) => element.id === elementId,
);
}
private findTrackIdForElement({
elementId,
}: {
elementId: string;
}): string | null {
const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
return null;
}
if (
activeScene.tracks.main.elements.some(
(element) => element.id === elementId,
)
) {
return activeScene.tracks.main.id;
}
for (const track of activeScene.tracks.overlay) {
if (track.elements.some((element) => element.id === elementId)) {
return track.id;
}
}
for (const track of activeScene.tracks.audio) {
if (track.elements.some((element) => element.id === elementId)) {
return track.id;
}
}
return null;
}
updateTracks(newTracks: SceneTracks): void {
this.previewOverlay.clear();
this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks });

View File

@ -5,6 +5,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import {
getElementsAtTime,
@ -110,10 +111,11 @@ export function useEditorActions() {
"frame-step-forward",
() => {
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + 1 / fps,
editor.playback.getCurrentTime() + ticksPerFrame,
),
});
},
@ -124,8 +126,9 @@ export function useEditorActions() {
"frame-step-backward",
() => {
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
});
},
undefined,
@ -176,11 +179,12 @@ export function useEditorActions() {
"split",
() => {
const currentTime = editor.playback.getCurrentTime();
const tracks = editor.scenes.getActiveScene().tracks;
const elementsToSplit =
selectedElements.length > 0
? selectedElements
: getElementsAtTime({
tracks: editor.timeline.getTracks(),
tracks,
time: currentTime,
});
@ -198,11 +202,12 @@ export function useEditorActions() {
"split-left",
() => {
const currentTime = editor.playback.getCurrentTime();
const tracks = editor.scenes.getActiveScene().tracks;
const elementsToSplit =
selectedElements.length > 0
? selectedElements
: getElementsAtTime({
tracks: editor.timeline.getTracks(),
tracks,
time: currentTime,
});
@ -212,7 +217,6 @@ export function useEditorActions() {
elements: elementsToSplit,
splitTime: currentTime,
retainSide: "right",
rippleEnabled: rippleEditingEnabled,
});
if (rippleEditingEnabled && rightSideElements.length > 0) {
@ -231,11 +235,12 @@ export function useEditorActions() {
"split-right",
() => {
const currentTime = editor.playback.getCurrentTime();
const tracks = editor.scenes.getActiveScene().tracks;
const elementsToSplit =
selectedElements.length > 0
? selectedElements
: getElementsAtTime({
tracks: editor.timeline.getTracks(),
tracks,
time: currentTime,
});
@ -263,7 +268,6 @@ export function useEditorActions() {
}
editor.timeline.deleteElements({
elements: selectedElements,
rippleEnabled: rippleEditingEnabled,
});
},
undefined,
@ -294,12 +298,7 @@ export function useEditorActions() {
null
);
})();
if (
!canToggleSourceAudio({
element: selectedElement.element,
mediaAsset,
})
) {
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
return;
}
@ -314,7 +313,12 @@ export function useEditorActions() {
useActionHandler(
"select-all",
() => {
const allElements = editor.timeline.getTracks().flatMap((track) =>
const scene = editor.scenes.getActiveScene();
const allElements = [
...scene.tracks.overlay,
scene.tracks.main,
...scene.tracks.audio,
].flatMap((track) =>
track.elements.map((element) => ({
trackId: track.id,
elementId: element.id,

View File

@ -8,11 +8,11 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useTimelineStore } from "@/stores/timeline-store";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
@ -21,6 +21,7 @@ import { registerCanceller } from "@/lib/cancel-interaction";
import type {
DropTarget,
ElementDragState,
SceneTracks,
TimelineElement,
TimelineTrack,
} from "@/lib/timeline";
@ -68,7 +69,8 @@ function getClickOffsetTime({
zoomLevel: number;
}): number {
const clickOffsetX = clientX - elementRect.left;
return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
return Math.round(seconds * TICKS_PER_SECOND);
}
function getVerticalDragDirection({
@ -100,7 +102,7 @@ function getDragDropTarget({
clientY: number;
elementId: string;
trackId: string;
tracks: TimelineTrack[];
tracks: SceneTracks;
tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
@ -112,7 +114,11 @@ function getDragDropTarget({
const scrollContainer = tracksScrollRef.current;
if (!containerRect || !scrollContainer) return null;
const sourceTrack = tracks.find(({ id }) => id === trackId);
const sourceTrack = [
...tracks.overlay,
tracks.main,
...tracks.audio,
].find(({ id }) => id === trackId);
const movingElement = sourceTrack?.elements.find(
({ id }) => id === elementId,
);
@ -161,9 +167,13 @@ export function useElementInteraction({
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const sceneTracks = editor.scenes.getActiveScene().tracks;
const tracks = [
...sceneTracks.overlay,
sceneTracks.main,
...sceneTracks.audio,
];
const {
isElementSelected,
selectElement,
@ -242,7 +252,7 @@ export function useElementInteraction({
const startSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
tracks,
tracks: sceneTracks,
playheadTime,
zoomLevel,
excludeElementId: movingElement.id,
@ -252,7 +262,7 @@ export function useElementInteraction({
const endSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
tracks,
tracks: sceneTracks,
playheadTime,
zoomLevel,
excludeElementId: movingElement.id,
@ -299,14 +309,17 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
const adjustedTime = Math.max(
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps });
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
const adjustedTime = Math.max(
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = roundToFrame({
time: adjustedTime,
rate: activeProject.settings.fps,
}) ?? adjustedTime;
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
initialCurrentMouseY: clientY,
});
startedDragThisEvent = true;
@ -346,7 +359,7 @@ export function useElementInteraction({
});
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps });
const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
@ -373,7 +386,7 @@ export function useElementInteraction({
clientY,
elementId: dragState.elementId,
trackId: dragState.trackId,
tracks,
tracks: sceneTracks,
tracksContainerRef,
tracksScrollRef,
headerRef,
@ -433,7 +446,7 @@ export function useElementInteraction({
clientY,
elementId: dragState.elementId,
trackId: dragState.trackId,
tracks,
tracks: sceneTracks,
tracksContainerRef,
tracksScrollRef,
headerRef,
@ -457,6 +470,18 @@ export function useElementInteraction({
onSnapPointChange?.(null);
return;
}
const movingElement =
sourceTrack.elements.find(({ id }) => id === dragState.elementId) ?? null;
if (
movingElement &&
!dropTarget.isNewTrack &&
tracks[dropTarget.trackIndex]?.id === dragState.trackId &&
snappedTime === movingElement.startTime
) {
endDrag();
onSnapPointChange?.(null);
return;
}
if (dropTarget.isNewTrack) {
const newTrackId = generateUUID();
@ -467,7 +492,6 @@ export function useElementInteraction({
elementId: dragState.elementId,
newStartTime: snappedTime,
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
rippleEnabled: rippleEditingEnabled,
});
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
} else {
@ -478,7 +502,6 @@ export function useElementInteraction({
targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
rippleEnabled: rippleEditingEnabled,
});
if (targetTrack.id !== dragState.trackId) {
selectElement({
@ -509,7 +532,6 @@ export function useElementInteraction({
tracksContainerRef,
tracksScrollRef,
headerRef,
rippleEditingEnabled,
selectElement,
]);

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { roundToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
@ -45,9 +46,6 @@ export function useTimelineElementResize({
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const rippleEditingEnabled = useTimelineStore(
(state) => state.rippleEditingEnabled,
);
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
@ -188,16 +186,17 @@ export function useTimelineElementResize({
({ clientX }: { clientX: number }) => {
if (!resizing) return;
const deltaX = clientX - resizing.startX;
let deltaTime =
deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const deltaX = clientX - resizing.startX;
let deltaTime = Math.round(
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND,
);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = editor.project.getActive().settings.fps;
const minDurationSeconds = 1 / projectFps;
const projectFps = editor.project.getActive().settings.fps;
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
const tracks = editor.timeline.getTracks();
const tracks = editor.scenes.getActiveScene().tracks;
const playheadTime = editor.playback.getCurrentTime();
const snapPoints = findSnapPoints({
tracks,
@ -280,19 +279,19 @@ export function useTimelineElementResize({
const maxAllowed =
sourceDuration -
resizing.initialTrimEnd -
getVisibleSourceSpanForDuration(minDurationSeconds);
getVisibleSourceSpanForDuration(minDuration);
const calculated =
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
const newTrimStart = roundToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), rate: projectFps }) ?? Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated));
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
const newStartTime = roundToFrame({ time: resizing.initialStartTime + trimDelta, rate: projectFps }) ?? resizing.initialStartTime + trimDelta;
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@ -314,8 +313,8 @@ export function useTimelineElementResize({
)
: Math.min(extensionAmount, maxExtension),
);
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension;
const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension;
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
@ -341,8 +340,8 @@ export function useTimelineElementResize({
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration);
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@ -369,7 +368,7 @@ export function useTimelineElementResize({
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
const newDuration = roundToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), rate: projectFps }) ?? Math.min(baseDuration + extensionNeeded, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@ -379,7 +378,7 @@ export function useTimelineElementResize({
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
const newDuration = roundToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), rate: projectFps }) ?? Math.min(unclampedDuration, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@ -398,17 +397,17 @@ export function useTimelineElementResize({
const maxTrimEnd =
sourceDuration -
resizing.initialTrimStart -
getVisibleSourceSpanForDuration(minDurationSeconds);
getVisibleSourceSpanForDuration(minDuration);
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd;
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
@ -458,7 +457,6 @@ export function useTimelineElementResize({
trimEnd: finalTrimEnd,
startTime: startTimeChanged ? finalStartTime : undefined,
duration: durationChanged ? finalDuration : undefined,
rippleEnabled: rippleEditingEnabled,
});
}
@ -471,7 +469,6 @@ export function useTimelineElementResize({
element.id,
onResizeStateChange,
onSnapPointChange,
rippleEditingEnabled,
]);
useEffect(() => {

View File

@ -6,10 +6,12 @@ import {
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { getKeyframeById } from "@/lib/animation";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { roundToFrame, snappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands";
@ -84,10 +86,11 @@ export function useKeyframeDrag({
deltaTime: number;
}) => {
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
const channel = element.animations?.channels[keyframeRef.propertyPath];
const keyframe = channel?.keyframes.find(
(keyframe) => keyframe.id === keyframeRef.keyframeId,
);
const keyframe = getKeyframeById({
animations: element.animations,
propertyPath: keyframeRef.propertyPath,
keyframeId: keyframeRef.keyframeId,
});
if (!keyframe) return [];
const nextTime = Math.max(
0,
@ -142,9 +145,9 @@ export function useKeyframeDrag({
if (!dragState.isDragging) return;
const startX = mouseDownXRef.current ?? clientX;
const rawDelta = (clientX - startX) / pixelsPerSecond;
const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
const startX = mouseDownXRef.current ?? clientX;
const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND);
const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
};
@ -253,7 +256,7 @@ export function useKeyframeDrag({
if (wasDrag) return;
const duration = editor.timeline.getTotalDuration();
const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
const seekTime = snappedSeekTime({ time: displayedStartTime + indicatorTime, duration, rate: fps }) ?? displayedStartTime + indicatorTime;
editor.playback.seek({ time: seekTime });
if (event.shiftKey) {

View File

@ -8,7 +8,7 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import {
findSnapPoints,
@ -44,7 +44,7 @@ export function useBookmarkDrag({
}: UseBookmarkDragProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const tracks = editor.scenes.getActiveScene().tracks;
const activeScene = editor.scenes.getActiveScene();
const bookmarks = activeScene?.bookmarks ?? [];
const playheadTime = editor.playback.getCurrentTime();
@ -148,7 +148,7 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
const frameSnappedTime = roundToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), rate: activeProject.settings.fps }) ?? Math.max(0, Math.min(mouseTime, duration));
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
@ -176,9 +176,9 @@ export function useBookmarkDrag({
scrollLeft,
});
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
const frameSnappedTime = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime;
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
});

View File

@ -3,9 +3,9 @@ import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
import {
buildTextElement,
buildGraphicElement,
@ -17,7 +17,6 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getDragData, hasDragData } from "@/lib/drag-data";
import { isMainTrack } from "@/lib/timeline/placement";
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type {
MediaDragData,
@ -47,7 +46,7 @@ export function useTimelineDragDrop({
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
const projectFps = editor.project.getActive().settings.fps;
return snapTimeToFrame({ time, fps: projectFps });
return roundToFrame({ time, rate: projectFps }) ?? time;
},
[editor],
);
@ -83,14 +82,14 @@ export function useTimelineDragDrop({
elementType === "sticker" ||
elementType === "effect"
) {
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
return DEFAULT_NEW_ELEMENT_DURATION;
}
if (mediaId) {
const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId);
return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
}
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
return DEFAULT_NEW_ELEMENT_DURATION;
},
[editor],
);
@ -149,13 +148,13 @@ export function useTimelineDragDrop({
? (dragData as MediaDragData).targetElementTypes
: undefined;
const tracks = editor.timeline.getTracks();
const sceneTracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime();
const target = computeDropTarget({
elementType,
mouseX,
mouseY,
tracks,
tracks: sceneTracks,
playheadTime: currentTime,
isExternalDrop: isExternal,
elementDuration: duration,
@ -230,7 +229,11 @@ export function useTimelineDragDrop({
return;
}
const tracks = editor.timeline.getTracks();
const tracks = [
...editor.scenes.getActiveScene().tracks.overlay,
editor.scenes.getActiveScene().tracks.main,
...editor.scenes.getActiveScene().tracks.audio,
];
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@ -267,7 +270,11 @@ export function useTimelineDragDrop({
return;
}
const tracks = editor.timeline.getTracks();
const tracks = [
...editor.scenes.getActiveScene().tracks.overlay,
editor.scenes.getActiveScene().tracks.main,
...editor.scenes.getActiveScene().tracks.audio,
];
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@ -305,7 +312,11 @@ export function useTimelineDragDrop({
return;
}
const tracks = editor.timeline.getTracks();
const tracks = [
...editor.scenes.getActiveScene().tracks.overlay,
editor.scenes.getActiveScene().tracks.main,
...editor.scenes.getActiveScene().tracks.audio,
];
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@ -331,7 +342,7 @@ export function useTimelineDragDrop({
dragData.mediaType === "audio" ? "audio" : "video";
const duration =
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: mediaAsset.id,
mediaType: mediaAsset.type,
@ -352,7 +363,11 @@ export function useTimelineDragDrop({
return;
}
const tracks = editor.timeline.getTracks();
const tracks = [
...editor.scenes.getActiveScene().tracks.overlay,
editor.scenes.getActiveScene().tracks.main,
...editor.scenes.getActiveScene().tracks.audio,
];
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@ -380,7 +395,11 @@ export function useTimelineDragDrop({
return;
}
const tracks = editor.timeline.getTracks();
const tracks = [
...editor.scenes.getActiveScene().tracks.overlay,
editor.scenes.getActiveScene().tracks.main,
...editor.scenes.getActiveScene().tracks.audio,
];
const effectTrack = tracks.find((t) => t.type === "effect");
let trackId: string;
@ -446,17 +465,15 @@ export function useTimelineDragDrop({
const duration =
createdAsset.duration ??
DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const currentTracks = editor.timeline.getTracks();
DEFAULT_NEW_ELEMENT_DURATION;
const sceneTracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0];
const reuseMainTrackId =
createdAsset.type !== "audio" &&
currentTracks.length === 1 &&
!!onlyTrack &&
isMainTrack(onlyTrack) &&
onlyTrack.elements.length === 0
? onlyTrack.id
sceneTracks.overlay.length === 0 &&
sceneTracks.audio.length === 0 &&
sceneTracks.main.elements.length === 0
? sceneTracks.main.id
: null;
const dropTarget = reuseMainTrackId
? null
@ -464,7 +481,7 @@ export function useTimelineDragDrop({
elementType: createdAsset.type,
mouseX,
mouseY,
tracks: currentTracks,
tracks: sceneTracks,
playheadTime: currentTime,
isExternalDrop: true,
elementDuration: duration,
@ -488,7 +505,11 @@ export function useTimelineDragDrop({
trackId = addTrackCmd.getTrackId();
editor.command.execute({ command: addTrackCmd });
} else {
trackId = currentTracks[dropTarget.trackIndex]?.id;
trackId = [
...sceneTracks.overlay,
sceneTracks.main,
...sceneTracks.audio,
][dropTarget.trackIndex]?.id;
}
}

View File

@ -1,4 +1,5 @@
import { getSnappedSeekTime } from "opencut-wasm";
import { snappedSeekTime } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
@ -6,6 +7,7 @@ import { useShiftKey } from "@/hooks/use-shift-key";
import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
import {
getCenteredLineLeft,
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
@ -66,29 +68,29 @@ export function useTimelinePlayhead({
const rulerRect = ruler.getBoundingClientRect();
const relativeMouseX = event.clientX - rulerRect.left;
const timelineContentWidth =
duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
const clampedMouseX = Math.max(
0,
Math.min(timelineContentWidth, relativeMouseX),
);
const clampedMouseX = Math.max(
0,
Math.min(timelineContentWidth, relativeMouseX),
);
const rawTime = Math.max(
0,
Math.min(
duration,
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTimeSeconds = Math.max(
0,
Math.min(
duration / TICKS_PER_SECOND,
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
const framesPerSecond = activeProject.settings.fps;
const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
const rate = activeProject.settings.fps;
const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
if (!shouldSnap) return frameTime;
const tracks = editor.timeline.getTracks();
const tracks = editor.scenes.getActiveScene().tracks;
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
const snapPoints = findSnapPoints({
tracks,
@ -161,7 +163,7 @@ export function useTimelinePlayhead({
getMouseClientX: () => lastMouseXRef.current,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
});
useEffect(() => {
@ -247,8 +249,10 @@ export function useTimelinePlayhead({
const tracksViewport = tracksScrollRef.current;
if (!rulerViewport || !tracksViewport) return;
const playheadPixels =
time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
const playheadPixels = timelineTimeToPixels({
time,
zoomLevel: zoomLevelRef.current,
});
const viewportWidth = rulerViewport.clientWidth;
const scrollMinimum = 0;
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;

View File

@ -1,7 +1,8 @@
import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { getSnappedSeekTime } from "opencut-wasm";
import { snappedSeekTime } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEditor } from "../use-editor";
interface UseTimelineSeekProps {
@ -127,17 +128,18 @@ export function useTimelineSeek({
const mouseX = event.clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
const rawTime = Math.max(
0,
Math.min(
duration,
(mouseX + scrollLeft) /
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTimeSeconds = Math.max(
0,
Math.min(
duration / TICKS_PER_SECOND,
(mouseX + scrollLeft) /
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
const projectFps = activeProject?.settings.fps || 30;
const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
const rate = activeProject?.settings.fps;
const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime;
seek(time);
editor.project.setTimelineViewState({
viewState: {
@ -154,7 +156,8 @@ export function useTimelineSeek({
tracksScrollRef,
seek,
editor,
activeProject?.settings.fps,
activeProject?.settings.fps.numerator,
activeProject?.settings.fps.denominator,
],
);

View File

@ -13,8 +13,8 @@ import {
import {
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
BASE_TIMELINE_PIXELS_PER_SECOND,
} from "@/lib/timeline/scale";
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
@ -186,10 +186,8 @@ export function useTimelineZoom({
}
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
const playheadPixelsAfter =
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom });
const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel });
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;

View File

@ -1,5 +1,5 @@
import { useEditor } from "@/hooks/use-editor";
import type { TimelineElement } from "@/lib/timeline";
import { findTrackInSceneTracks, type TimelineElement } from "@/lib/timeline";
/**
* Subscribes to render tracks and returns the live (preview-aware) version of
@ -18,13 +18,14 @@ export function useElementPreview<T extends TimelineElement>({
fallback: T;
}) {
const editor = useEditor();
useEditor((e) => e.timeline.getRenderTracks());
useEditor((e) => e.timeline.getPreviewTracks());
const previewTracks = editor.timeline.getPreviewTracks();
const renderElement =
(editor.timeline
.getRenderTracks()
.find((t) => t.id === trackId)
?.elements.find((el) => el.id === elementId) as T | undefined) ??
(findTrackInSceneTracks({
tracks: previewTracks ?? editor.scenes.getActiveScene().tracks,
trackId,
})?.elements.find((element) => element.id === elementId) as T | undefined) ??
fallback;
const previewUpdates = (updates: Partial<TimelineElement>) =>

View File

@ -45,7 +45,9 @@ export function useMaskHandles({
null,
);
const tracks = useEditor((e) => e.timeline.getRenderTracks());
const tracks = useEditor(
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
);
const currentTime = useEditor((e) => e.playback.getCurrentTime());
const mediaAssets = useEditor((e) => e.media.getAssets());
const canvasSize = useEditor(

View File

@ -1,114 +1,114 @@
import { useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { invokeAction } from "@/lib/actions";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/lib/media/types";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
function isMediaMimeType({ type }: { type: string }): boolean {
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
}
function extractMediaFilesFromClipboard({
clipboardData,
}: {
clipboardData: DataTransfer | null;
}): File[] {
if (!clipboardData?.items) return [];
const files: File[] = [];
for (const item of clipboardData.items) {
if (item.kind !== "file") continue;
if (!isMediaMimeType({ type: item.type })) continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
}
export function usePasteMedia() {
const editor = useEditor();
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
if (files.length === 0) {
event.preventDefault();
invokeAction("paste-copied");
return;
}
event.preventDefault();
const activeProject = editor.project.getActive();
if (!activeProject) return;
try {
await showMediaUploadToast({
filesCount: files.length,
promise: async () => {
const processedAssets = await processMediaAssets({ files });
const startTime = editor.playback.getCurrentTime();
for (const asset of processedAssets) {
const addMediaCmd = new AddMediaAssetCommand(
activeProject.metadata.id,
asset,
);
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({
mediaId: assetId,
mediaType: asset.type,
name: asset.name,
duration,
startTime,
buffer:
asset.type === "audio"
? new AudioBuffer({ length: 1, sampleRate: 44100 })
: undefined,
});
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "auto", trackType },
});
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
editor.command.execute({ command: batchCmd });
}
return {
uploadedCount: processedAssets.length,
assetNames: processedAssets.map((asset) => asset.name),
};
},
});
} catch (error) {
console.error("Failed to paste media:", error);
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor]);
}
import { useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { invokeAction } from "@/lib/actions";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/lib/media/types";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
function isMediaMimeType({ type }: { type: string }): boolean {
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
}
function extractMediaFilesFromClipboard({
clipboardData,
}: {
clipboardData: DataTransfer | null;
}): File[] {
if (!clipboardData?.items) return [];
const files: File[] = [];
for (const item of clipboardData.items) {
if (item.kind !== "file") continue;
if (!isMediaMimeType({ type: item.type })) continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
}
export function usePasteMedia() {
const editor = useEditor();
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
if (files.length === 0) {
event.preventDefault();
invokeAction("paste-copied");
return;
}
event.preventDefault();
const activeProject = editor.project.getActive();
if (!activeProject) return;
try {
await showMediaUploadToast({
filesCount: files.length,
promise: async () => {
const processedAssets = await processMediaAssets({ files });
const startTime = editor.playback.getCurrentTime();
for (const asset of processedAssets) {
const addMediaCmd = new AddMediaAssetCommand(
activeProject.metadata.id,
asset,
);
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({
mediaId: assetId,
mediaType: asset.type,
name: asset.name,
duration,
startTime,
buffer:
asset.type === "audio"
? new AudioBuffer({ length: 1, sampleRate: 44100 })
: undefined,
});
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "auto", trackType },
});
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
editor.command.execute({ command: batchCmd });
}
return {
uploadedCount: processedAssets.length,
assetNames: processedAssets.map((asset) => asset.name),
};
},
});
} catch (error) {
console.error("Failed to paste media:", error);
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor]);
}

View File

@ -180,7 +180,7 @@ export function usePreviewInteraction({
({ clientX, clientY }: React.MouseEvent) => {
if (editingText || isMaskMode) return;
const tracks = editor.timeline.getTracks();
const tracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
@ -238,7 +238,7 @@ export function usePreviewInteraction({
if (isMaskMode) return;
if (button !== 0) return;
const tracks = editor.timeline.getTracks();
const tracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import {
composeAnimationValue,
createAnimationBinding,
} from "@/lib/animation/binding-values";
describe("binding values", () => {
test("formats composed animated colors as hex", () => {
const binding = createAnimationBinding({
path: "color",
kind: "color",
});
expect(
composeAnimationValue({
binding,
componentValues: {
r: 1,
g: 0,
b: 0,
a: 1,
},
}),
).toBe("#ff0000");
});
});

View File

@ -0,0 +1,139 @@
import { describe, expect, test } from "bun:test";
import {
getElementKeyframes,
getKeyframeById,
getKeyframeAtTime,
} from "@/lib/animation/keyframe-query";
import type {
ElementAnimations,
ScalarAnimationKey,
} from "@/lib/animation/types";
function createScalarKey({
id,
time,
value,
}: {
id: string;
time: number;
value: number;
}): ScalarAnimationKey {
return {
id,
time,
value,
segmentToNext: "linear",
tangentMode: "flat",
};
}
function buildPositionAnimations({
xKeys,
yKeys,
}: {
xKeys: ScalarAnimationKey[];
yKeys: ScalarAnimationKey[];
}): ElementAnimations {
return {
bindings: {
"transform.position": {
path: "transform.position",
kind: "vector2",
components: [
{ key: "x", channelId: "transform.position:x" },
{ key: "y", channelId: "transform.position:y" },
],
},
},
channels: {
"transform.position:x": {
kind: "scalar",
keys: xKeys,
},
"transform.position:y": {
kind: "scalar",
keys: yKeys,
},
},
};
}
describe("keyframe query", () => {
test("returns keyframes from any component channel", () => {
const animations = buildPositionAnimations({
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
});
expect(
getElementKeyframes({ animations }).map(({ id, time }) => ({
id,
time,
})),
).toEqual([
{ id: "x-1", time: 1 },
{ id: "y-2", time: 2 },
]);
});
test("finds a keyframe at time on a non-primary component", () => {
const animations = buildPositionAnimations({
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
});
expect(
getKeyframeAtTime({
animations,
propertyPath: "transform.position",
time: 2,
}),
).toMatchObject({
id: "y-2",
time: 2,
});
});
test("finds a keyframe by id on a non-primary component", () => {
const animations = buildPositionAnimations({
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
});
expect(
getKeyframeById({
animations,
propertyPath: "transform.position",
keyframeId: "y-2",
}),
).toMatchObject({
id: "y-2",
time: 2,
value: { x: 10, y: 20 },
});
});
test("prefers the primary component when multiple components share a time", () => {
const animations = buildPositionAnimations({
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
yKeys: [createScalarKey({ id: "y-1", time: 1, value: 20 })],
});
expect(
getElementKeyframes({ animations }).map(({ id, time }) => ({
id,
time,
})),
).toEqual([{ id: "x-1", time: 1 }]);
expect(
getKeyframeAtTime({
animations,
propertyPath: "transform.position",
time: 1,
}),
).toMatchObject({
id: "x-1",
time: 1,
});
});
});

View File

@ -0,0 +1,90 @@
import type { ScalarAnimationKey } from "@/lib/animation/types";
const BEZIER_SOLVE_ITERATIONS = 20;
export function getBezierPoint({
progress,
p0,
p1,
p2,
p3,
}: {
progress: number;
p0: number;
p1: number;
p2: number;
p3: number;
}) {
const mt = 1 - progress;
return (
mt * mt * mt * p0 +
3 * mt * mt * progress * p1 +
3 * mt * progress * progress * p2 +
progress * progress * progress * p3
);
}
export function getDefaultRightHandle({
leftKey,
rightKey,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
const span = rightKey.time - leftKey.time;
const valueDelta = rightKey.value - leftKey.value;
return {
dt: span / 3,
dv: valueDelta / 3,
};
}
export function getDefaultLeftHandle({
leftKey,
rightKey,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
const span = rightKey.time - leftKey.time;
const valueDelta = rightKey.value - leftKey.value;
return {
dt: -span / 3,
dv: -valueDelta / 3,
};
}
export function solveBezierProgressForTime({
time,
leftKey,
rightKey,
}: {
time: number;
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
let lower = 0;
let upper = 1;
const rightHandle =
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
const leftHandle =
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) {
const mid = (lower + upper) / 2;
const estimate = getBezierPoint({
progress: mid,
p0: leftKey.time,
p1: leftKey.time + rightHandle.dt,
p2: rightKey.time + leftHandle.dt,
p3: rightKey.time,
});
if (estimate < time) {
lower = mid;
} else {
upper = mid;
}
}
return (lower + upper) / 2;
}

View File

@ -0,0 +1,332 @@
import { converter, formatHex, formatHex8, parse } from "culori";
import type {
AnimationBindingComponent,
AnimationBindingOfKind,
AnimationBindingInstance,
AnimationBindingKind,
ColorAnimationBinding,
DiscreteAnimationBinding,
NumberAnimationBinding,
AnimationPath,
AnimationValue,
DiscreteValue,
Vector2AnimationBinding,
VectorValue,
} from "@/lib/animation/types";
import { clamp } from "@/utils/math";
interface LinearRgba {
r: number;
g: number;
b: number;
a: number;
}
export type AnimationComponentValue = number | DiscreteValue;
const toRgb = converter("rgb");
function srgbToLinear({ value }: { value: number }): number {
return value <= 0.04045
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
}
function linearToSrgb({ value }: { value: number }): number {
const clamped = clamp({ value, min: 0, max: 1 });
return clamped <= 0.0031308
? clamped * 12.92
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isVectorValue(value: unknown): value is VectorValue {
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
}
export function getBindingComponentKeys({
kind,
}: {
kind: AnimationBindingKind;
}): string[] {
if (kind === "vector2") {
return ["x", "y"];
}
if (kind === "color") {
return ["r", "g", "b", "a"];
}
return ["value"];
}
export function buildBindingChannelId({
path,
componentKey,
}: {
path: AnimationPath;
componentKey: string;
}): string {
return `${path}:${componentKey}`;
}
function createBindingComponent<TKey extends string>({
path,
key,
}: {
path: AnimationPath;
key: TKey;
}): AnimationBindingComponent<TKey> {
return {
key,
channelId: buildBindingChannelId({ path, componentKey: key }),
};
}
function cloneBindingComponents<TKey extends string>({
components,
}: {
components: AnimationBindingComponent<TKey>[];
}): AnimationBindingComponent<TKey>[] {
return components.map((component) => ({ ...component }));
}
const animationBindingFactories = {
color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
path,
kind: "color",
colorSpace: "srgb-linear",
components: [
createBindingComponent({ path, key: "r" }),
createBindingComponent({ path, key: "g" }),
createBindingComponent({ path, key: "b" }),
createBindingComponent({ path, key: "a" }),
],
}),
vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
path,
kind: "vector2",
components: [
createBindingComponent({ path, key: "x" }),
createBindingComponent({ path, key: "y" }),
],
}),
number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
path,
kind: "number",
components: [createBindingComponent({ path, key: "value" })],
}),
discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
path,
kind: "discrete",
components: [createBindingComponent({ path, key: "value" })],
}),
} satisfies {
[K in AnimationBindingKind]: ({
path,
}: {
path: AnimationPath;
}) => AnimationBindingOfKind<K>;
};
export function createAnimationBinding<TKind extends AnimationBindingKind>({
path,
kind,
}: {
path: AnimationPath;
kind: TKind;
}): AnimationBindingOfKind<TKind>;
export function createAnimationBinding({
path,
kind,
}: {
path: AnimationPath;
kind: AnimationBindingKind;
}): AnimationBindingInstance {
return animationBindingFactories[kind]({ path });
}
const animationBindingCloners = {
color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
vector2: ({
binding,
}: {
binding: Vector2AnimationBinding;
}): Vector2AnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
number: ({
binding,
}: {
binding: NumberAnimationBinding;
}): NumberAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
discrete: ({
binding,
}: {
binding: DiscreteAnimationBinding;
}): DiscreteAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
} satisfies {
[K in AnimationBindingKind]: ({
binding,
}: {
binding: AnimationBindingOfKind<K>;
}) => AnimationBindingOfKind<K>;
};
export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
binding,
}: {
binding: AnimationBindingOfKind<TKind>;
}): AnimationBindingOfKind<TKind>;
export function cloneAnimationBinding({
binding,
}: {
binding: AnimationBindingInstance;
}): AnimationBindingInstance {
switch (binding.kind) {
case "color":
return animationBindingCloners.color({ binding });
case "vector2":
return animationBindingCloners.vector2({ binding });
case "number":
return animationBindingCloners.number({ binding });
case "discrete":
return animationBindingCloners.discrete({ binding });
}
}
export function parseColorToLinearRgba({
color,
}: {
color: string;
}): LinearRgba | null {
const parsed = parse(color);
const rgb = parsed ? toRgb(parsed) : null;
if (!rgb) {
return null;
}
return {
r: srgbToLinear({ value: rgb.r ?? 0 }),
g: srgbToLinear({ value: rgb.g ?? 0 }),
b: srgbToLinear({ value: rgb.b ?? 0 }),
a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
};
}
export function formatLinearRgba({
color,
}: {
color: LinearRgba;
}): string {
const rgb = {
mode: "rgb",
r: linearToSrgb({ value: color.r }),
g: linearToSrgb({ value: color.g }),
b: linearToSrgb({ value: color.b }),
alpha: clamp({ value: color.a, min: 0, max: 1 }),
} as const;
return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
}
export function decomposeAnimationValue({
kind,
value,
}: {
kind: AnimationBindingKind;
value: AnimationValue;
}): Record<string, AnimationComponentValue> | null {
if (kind === "number") {
return typeof value === "number" ? { value } : null;
}
if (kind === "vector2") {
return isVectorValue(value) ? { x: value.x, y: value.y } : null;
}
if (kind === "color") {
if (typeof value !== "string") {
return null;
}
const parsed = parseColorToLinearRgba({ color: value });
if (!parsed) {
return null;
}
return {
r: parsed.r,
g: parsed.g,
b: parsed.b,
a: parsed.a,
};
}
return typeof value === "string" || typeof value === "boolean"
? { value }
: null;
}
export function composeAnimationValue({
binding,
componentValues,
}: {
binding: AnimationBindingInstance;
componentValues: Record<string, AnimationComponentValue | undefined>;
}): AnimationValue | null {
if (binding.kind === "number") {
const value = componentValues.value;
return typeof value === "number" ? value : null;
}
if (binding.kind === "vector2") {
const x = componentValues.x;
const y = componentValues.y;
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
}
if (binding.kind === "color") {
const r = componentValues.r;
const g = componentValues.g;
const b = componentValues.b;
const a = componentValues.a;
if (
typeof r !== "number" ||
typeof g !== "number" ||
typeof b !== "number" ||
typeof a !== "number"
) {
return null;
}
return formatLinearRgba({
color: {
r,
g,
b,
a,
},
});
}
const value = componentValues.value;
return typeof value === "string" || typeof value === "boolean" ? value : null;
}

View File

@ -1,19 +0,0 @@
import type {
AnimationPropertyPath,
ColorAnimationChannel,
ElementAnimations,
} from "@/lib/animation/types";
export function getColorChannelForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
}): ColorAnimationChannel | undefined {
const channel = animations?.channels[propertyPath];
if (!channel || channel.valueKind !== "color") {
return undefined;
}
return channel;
}

View File

@ -0,0 +1,81 @@
import {
getDefaultLeftHandle,
getDefaultRightHandle,
} from "@/lib/animation/bezier";
import type {
CurveHandle,
NormalizedCubicBezier,
ScalarAnimationKey,
} from "@/lib/animation/types";
const VALUE_EPSILON = 1e-6;
function clamp01({ value }: { value: number }): number {
return Math.max(0, Math.min(1, value));
}
export function getNormalizedCubicBezierForScalarSegment({
leftKey,
rightKey,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}): NormalizedCubicBezier | null {
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
if (
spanTime === 0 ||
Math.abs(spanValue) <= VALUE_EPSILON
) {
return null;
}
const rightHandle =
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
const leftHandle =
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
return [
clamp01({ value: rightHandle.dt / spanTime }),
rightHandle.dv / spanValue,
clamp01({ value: 1 + leftHandle.dt / spanTime }),
1 + leftHandle.dv / spanValue,
];
}
export function getCurveHandlesForNormalizedCubicBezier({
leftKey,
rightKey,
cubicBezier,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
cubicBezier: NormalizedCubicBezier;
}): {
rightHandle: CurveHandle;
leftHandle: CurveHandle;
} | null {
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
if (
spanTime === 0 ||
Math.abs(spanValue) <= VALUE_EPSILON
) {
return null;
}
const [rawX1, y1, rawX2, y2] = cubicBezier;
const x1 = clamp01({ value: rawX1 });
const x2 = clamp01({ value: rawX2 });
return {
rightHandle: {
dt: spanTime * x1,
dv: spanValue * y1,
},
leftHandle: {
dt: spanTime * (x2 - 1),
dv: spanValue * (y2 - 1),
},
};
}

View File

@ -3,15 +3,9 @@ import type { Effect } from "@/lib/effects/types";
import type {
ElementAnimations,
EffectParamPath,
NumberAnimationChannel,
} from "@/lib/animation/types";
import {
getChannel,
removeKeyframe,
setChannel,
upsertKeyframe,
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
import { removeElementKeyframe } from "./keyframes";
import { resolveAnimationPathValueAtTime } from "./resolve";
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
const channel = getChannel({ animations, propertyPath: path });
if (channel && channel.keyframes.length > 0) {
resolved[paramKey] = getChannelValueAtTime({
channel,
time: localTime,
fallbackValue: staticValue,
}) as number | string | boolean;
} else {
resolved[paramKey] = staticValue;
}
resolved[paramKey] = animations?.bindings[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
localTime,
fallbackValue: staticValue,
})
: staticValue;
}
return resolved;
}
const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
valueKind: "number",
keyframes: [],
};
export function upsertEffectParamKeyframe({
animations,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
effectId: string;
paramKey: string;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const targetChannel =
channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
const updatedChannel = upsertKeyframe({
channel: targetChannel,
time,
value,
interpolation: interpolation ?? "linear",
keyframeId,
});
return (
setChannel({
animations,
propertyPath: path,
channel: updatedChannel,
}) ?? { channels: {} }
);
}
export function removeEffectParamKeyframe({
animations,
effectId,
@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({
paramKey: string;
keyframeId: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const updatedChannel = removeKeyframe({ channel, keyframeId });
return setChannel({
return removeElementKeyframe({
animations,
propertyPath: path,
channel: updatedChannel,
propertyPath: buildEffectParamPath({ effectId, paramKey }),
keyframeId,
});
}

View File

@ -0,0 +1,95 @@
import type {
AnimationPath,
ElementAnimations,
ScalarAnimationChannel,
ScalarGraphChannel,
ScalarGraphKeyframeContext,
} from "@/lib/animation/types";
function isScalarAnimationChannel(
channel: ElementAnimations["channels"][string],
): channel is ScalarAnimationChannel {
return channel?.kind === "scalar";
}
export function getEditableScalarChannels({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): ScalarGraphChannel[] {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return [];
}
return binding.components.flatMap((component) => {
const channel = animations?.channels[component.channelId];
if (!isScalarAnimationChannel(channel)) {
return [];
}
return [
{
propertyPath,
componentKey: component.key,
channelId: component.channelId,
channel,
},
];
});
}
export function getEditableScalarChannel({
animations,
propertyPath,
componentKey,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
componentKey: string;
}): ScalarGraphChannel | null {
return (
getEditableScalarChannels({
animations,
propertyPath,
}).find((channel) => channel.componentKey === componentKey) ?? null
);
}
export function getScalarKeyframeContext({
animations,
propertyPath,
componentKey,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
componentKey: string;
keyframeId: string;
}): ScalarGraphKeyframeContext | null {
const scalarChannel = getEditableScalarChannel({
animations,
propertyPath,
componentKey,
});
if (!scalarChannel) {
return null;
}
const keyframeIndex = scalarChannel.channel.keys.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
if (keyframeIndex < 0) {
return null;
}
return {
...scalarChannel,
keyframe: scalarChannel.channel.keys[keyframeIndex],
keyframeIndex,
previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null,
nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null,
};
}

View File

@ -1,77 +1,73 @@
import type {
ElementAnimations,
GraphicParamPath,
} from "@/lib/animation/types";
import type { ParamValues } from "@/lib/params";
import {
getGraphicDefinition,
resolveGraphicParams,
} from "@/lib/graphics";
import { getChannel } from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
export function buildGraphicParamPath({
paramKey,
}: {
paramKey: string;
}): GraphicParamPath {
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
}
export function isGraphicParamPath(
propertyPath: string,
): propertyPath is GraphicParamPath {
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
}
export function parseGraphicParamPath({
propertyPath,
}: {
propertyPath: string;
}): { paramKey: string } | null {
if (!isGraphicParamPath(propertyPath)) {
return null;
}
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
return paramKey.length > 0 ? { paramKey } : null;
}
export function resolveGraphicParamsAtTime({
element,
localTime,
}: {
element: {
definitionId: string;
params: ParamValues;
animations?: ElementAnimations;
};
localTime: number;
}): ParamValues {
const definition = getGraphicDefinition({
definitionId: element.definitionId,
});
const baseParams = resolveGraphicParams(definition, element.params);
const resolved: ParamValues = { ...baseParams };
for (const param of definition.params) {
const path = buildGraphicParamPath({ paramKey: param.key });
const channel = getChannel({
animations: element.animations,
propertyPath: path,
});
if (!channel || channel.keyframes.length === 0) {
continue;
}
resolved[param.key] = getChannelValueAtTime({
channel,
time: Math.max(0, localTime),
fallbackValue: baseParams[param.key] ?? param.default,
}) as number | string | boolean;
}
return resolved;
}
import type {
ElementAnimations,
GraphicParamPath,
} from "@/lib/animation/types";
import type { ParamValues } from "@/lib/params";
import {
getGraphicDefinition,
resolveGraphicParams,
} from "@/lib/graphics";
import { resolveAnimationPathValueAtTime } from "./resolve";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
export function buildGraphicParamPath({
paramKey,
}: {
paramKey: string;
}): GraphicParamPath {
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
}
export function isGraphicParamPath(
propertyPath: string,
): propertyPath is GraphicParamPath {
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
}
export function parseGraphicParamPath({
propertyPath,
}: {
propertyPath: string;
}): { paramKey: string } | null {
if (!isGraphicParamPath(propertyPath)) {
return null;
}
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
return paramKey.length > 0 ? { paramKey } : null;
}
export function resolveGraphicParamsAtTime({
element,
localTime,
}: {
element: {
definitionId: string;
params: ParamValues;
animations?: ElementAnimations;
};
localTime: number;
}): ParamValues {
const definition = getGraphicDefinition({
definitionId: element.definitionId,
});
const baseParams = resolveGraphicParams(definition, element.params);
const resolved: ParamValues = { ...baseParams };
for (const param of definition.params) {
const path = buildGraphicParamPath({ paramKey: param.key });
if (!element.animations?.bindings[path]) {
continue;
}
resolved[param.key] = resolveAnimationPathValueAtTime({
animations: element.animations,
propertyPath: path,
localTime: Math.max(0, localTime),
fallbackValue: baseParams[param.key] ?? param.default,
});
}
return resolved;
}

View File

@ -1,71 +1,94 @@
export {
getChannelValueAtTime,
getNumberChannelValueAtTime,
getVectorChannelValueAtTime,
normalizeChannel,
} from "./interpolation";
export {
clampAnimationsToDuration,
cloneAnimations,
getChannel,
removeElementKeyframe,
retimeElementKeyframe,
setChannel,
splitAnimationsAtTime,
upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
export {
getElementLocalTime,
resolveColorAtTime,
resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "./resolve";
export {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getDefaultInterpolationForProperty,
getElementBaseValueForProperty,
isAnimationPropertyPath,
supportsAnimationProperty,
type AnimationPropertyDefinition,
type NumericSpec,
type NumericRange,
withElementBaseValueForProperty,
} from "./property-registry";
export {
getElementKeyframes,
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
export {
buildGraphicParamPath,
isGraphicParamPath,
parseGraphicParamPath,
resolveGraphicParamsAtTime,
} from "./graphic-param-channel";
export {
isAnimationPath,
resolveAnimationTarget,
getParamValueKind,
getParamDefaultInterpolation,
type AnimationPathDescriptor,
} from "./target-resolver";
export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
type GroupKeyframeRef,
} from "./property-groups";
export {
getVectorChannelForPath,
isVectorValue,
} from "./vector-channel";
export {
getChannelValueAtTime,
getDiscreteChannelValueAtTime,
getScalarChannelValueAtTime,
getScalarSegmentInterpolation,
normalizeChannel,
} from "./interpolation";
export {
clampAnimationsToDuration,
cloneAnimations,
getChannel,
removeElementKeyframe,
retimeElementKeyframe,
setBindingComponentChannel,
setChannel,
splitAnimationsAtTime,
updateScalarKeyframeCurve,
upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
export {
getElementLocalTime,
resolveAnimationPathValueAtTime,
resolveColorAtTime,
resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "./resolve";
export {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getDefaultInterpolationForProperty,
getElementBaseValueForProperty,
isAnimationPropertyPath,
supportsAnimationProperty,
type AnimationPropertyDefinition,
type NumericSpec,
withElementBaseValueForProperty,
} from "./property-registry";
export {
getElementKeyframes,
getKeyframeById,
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
export {
getEditableScalarChannel,
getEditableScalarChannels,
getScalarKeyframeContext,
} from "./graph-channels";
export {
getCurveHandlesForNormalizedCubicBezier,
getNormalizedCubicBezierForScalarSegment,
} from "./curve-bridge";
export {
buildGraphicParamPath,
isGraphicParamPath,
parseGraphicParamPath,
resolveGraphicParamsAtTime,
} from "./graphic-param-channel";
export {
buildEffectParamPath,
isEffectParamPath,
parseEffectParamPath,
removeEffectParamKeyframe,
resolveEffectParamsAtTime,
} from "./effect-param-channel";
export {
isAnimationPath,
coerceAnimationValueForParam,
resolveAnimationTarget,
getParamValueKind,
getParamDefaultInterpolation,
type AnimationPathDescriptor,
} from "./target-resolver";
export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
type GroupKeyframeRef,
} from "./property-groups";
export {
isVectorValue,
} from "./binding-values";

View File

@ -1,15 +1,20 @@
import type {
AnimationChannel,
AnimationInterpolation,
AnimationValue,
ColorAnimationChannel,
DiscreteValue,
DiscreteAnimationChannel,
NumberAnimationChannel,
VectorAnimationChannel,
VectorValue,
DiscreteValue,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
} from "@/lib/animation/types";
import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { clamp } from "@/utils/math";
import {
getBezierPoint,
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
} from "./bezier";
function byTimeAscending({
leftTime,
@ -30,77 +35,7 @@ function isWithinTimePair({
leftTime: number;
rightTime: number;
}): boolean {
return (
time >= leftTime - TIME_EPSILON_SECONDS &&
time <= rightTime + TIME_EPSILON_SECONDS
);
}
function clamp01({ value }: { value: number }): number {
return Math.max(0, Math.min(1, value));
}
function parseHexChannel({ hex }: { hex: string }): number | null {
const value = Number.parseInt(hex, 16);
return Number.isNaN(value) ? null : value;
}
function parseHexColor({
color,
}: {
color: string;
}): { red: number; green: number; blue: number; alpha: number } | null {
const trimmed = color.trim();
if (!trimmed.startsWith("#")) {
return null;
}
const rawHex = trimmed.slice(1);
if (rawHex.length === 3 || rawHex.length === 4) {
const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split("");
const red = parseHexChannel({ hex: `${redHex}${redHex}` });
const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
if (red === null || green === null || blue === null || alpha === null) {
return null;
}
return { red, green, blue, alpha: alpha / 255 };
}
if (rawHex.length === 6 || rawHex.length === 8) {
const red = parseHexChannel({ hex: rawHex.slice(0, 2) });
const green = parseHexChannel({ hex: rawHex.slice(2, 4) });
const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
const alpha = parseHexChannel({ hex: alphaHex });
if (red === null || green === null || blue === null || alpha === null) {
return null;
}
return { red, green, blue, alpha: alpha / 255 };
}
return null;
}
function formatRgbaColor({
red,
green,
blue,
alpha,
}: {
red: number;
green: number;
blue: number;
alpha: number;
}): string {
const roundedRed = Math.round(red);
const roundedGreen = Math.round(green);
const roundedBlue = Math.round(blue);
const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000;
return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`;
return time >= leftTime && time <= rightTime;
}
function lerpNumber({
@ -115,43 +50,99 @@ function lerpNumber({
return leftValue + (rightValue - leftValue) * progress;
}
function interpolateColor({
leftColor,
rightColor,
progress,
function normalizeRightHandle({
handle,
leftKey,
rightKey,
}: {
leftColor: string;
rightColor: string;
progress: number;
}): string {
const leftParsed = parseHexColor({ color: leftColor });
const rightParsed = parseHexColor({ color: rightColor });
if (!leftParsed || !rightParsed) {
return progress >= 1 ? rightColor : leftColor;
handle: ScalarAnimationKey["rightHandle"];
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
if (!handle) {
return undefined;
}
return formatRgbaColor({
red: lerpNumber({
leftValue: leftParsed.red,
rightValue: rightParsed.red,
progress,
}),
green: lerpNumber({
leftValue: leftParsed.green,
rightValue: rightParsed.green,
progress,
}),
blue: lerpNumber({
leftValue: leftParsed.blue,
rightValue: rightParsed.blue,
progress,
}),
alpha: lerpNumber({
leftValue: leftParsed.alpha,
rightValue: rightParsed.alpha,
progress,
}),
const span = Math.max(1, rightKey.time - leftKey.time);
return {
dt: Math.min(span, Math.max(0, handle.dt)),
dv: handle.dv,
};
}
function normalizeLeftHandle({
handle,
leftKey,
rightKey,
}: {
handle: ScalarAnimationKey["leftHandle"];
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
}) {
if (!handle) {
return undefined;
}
const span = Math.max(1, rightKey.time - leftKey.time);
return {
dt: Math.max(-span, Math.min(0, handle.dt)),
dv: handle.dv,
};
}
function normalizeScalarKey({
key,
}: {
key: ScalarAnimationKey;
}): ScalarAnimationKey {
return {
...key,
tangentMode: key.tangentMode ?? "flat",
segmentToNext: key.segmentToNext ?? "linear",
};
}
function normalizeScalarChannel({
channel,
}: {
channel: ScalarAnimationChannel;
}): ScalarAnimationChannel {
const sortedKeys = [...channel.keys]
.map((key) => normalizeScalarKey({ key }))
.sort((leftKey, rightKey) =>
byTimeAscending({
leftTime: leftKey.time,
rightTime: rightKey.time,
}),
);
const nextKeys = sortedKeys.map((key, index) => {
const previousKey = sortedKeys[index - 1];
const nextKey = sortedKeys[index + 1];
return {
...key,
leftHandle:
previousKey != null
? normalizeLeftHandle({
handle: key.leftHandle,
leftKey: previousKey,
rightKey: key,
})
: undefined,
rightHandle:
nextKey != null
? normalizeRightHandle({
handle: key.rightHandle,
leftKey: key,
rightKey: nextKey,
})
: undefined,
};
});
return {
...channel,
keys: nextKeys,
};
}
export function normalizeChannel<TChannel extends AnimationChannel>({
@ -159,9 +150,15 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
}: {
channel: TChannel;
}): TChannel {
if (channel.kind === "scalar") {
return normalizeScalarChannel({
channel,
}) as TChannel;
}
return {
...channel,
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
byTimeAscending({
leftTime: leftKeyframe.time,
rightTime: rightKeyframe.time,
@ -170,171 +167,154 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
} as TChannel;
}
function evaluateChannelValueAtTime<
TKeyframe extends { time: number; value: TValue },
TValue,
>({
keyframes,
function extrapolateScalarEdge({
mode,
edgeKey,
neighborKey,
time,
fallbackValue,
getInterpolatedValue,
}: {
keyframes: TKeyframe[] | undefined;
mode: "hold" | "linear";
edgeKey: ScalarAnimationKey;
neighborKey: ScalarAnimationKey | undefined;
time: number;
fallbackValue: TValue;
getInterpolatedValue: ({
leftKeyframe,
rightKeyframe,
progress,
}: {
leftKeyframe: TKeyframe;
rightKeyframe: TKeyframe;
progress: number;
}) => TValue;
}): TValue {
if (!keyframes || keyframes.length === 0) {
return fallbackValue;
}) {
if (mode === "hold" || !neighborKey) {
return edgeKey.value;
}
const firstKeyframe = keyframes[0];
const lastKeyframe = keyframes[keyframes.length - 1];
if (!firstKeyframe || !lastKeyframe) {
return fallbackValue;
const span = neighborKey.time - edgeKey.time;
if (span === 0) {
return edgeKey.value;
}
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
return firstKeyframe.value;
}
if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
return lastKeyframe.value;
}
for (
let keyframeIndex = 0;
keyframeIndex < keyframes.length - 1;
keyframeIndex++
) {
const leftKeyframe = keyframes[keyframeIndex];
const rightKeyframe = keyframes[keyframeIndex + 1];
if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
return rightKeyframe.value;
}
const isBetweenPair = isWithinTimePair({
time,
leftTime: leftKeyframe.time,
rightTime: rightKeyframe.time,
});
if (!isBetweenPair) {
continue;
}
const span = rightKeyframe.time - leftKeyframe.time;
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
return rightKeyframe.value;
}
const progress = clamp01({
value: (time - leftKeyframe.time) / span,
});
return getInterpolatedValue({
leftKeyframe,
rightKeyframe,
progress,
});
}
return lastKeyframe.value;
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
}
export function getNumberChannelValueAtTime({
export function getScalarSegmentInterpolation({
segment,
}: {
segment: ScalarSegmentType;
}): AnimationInterpolation {
if (segment === "step") {
return "hold";
}
return segment === "bezier" ? "bezier" : "linear";
}
export function getScalarChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: NumberAnimationChannel | undefined;
channel: ScalarAnimationChannel | undefined;
time: number;
fallbackValue: number;
}): number {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
if (leftKeyframe.interpolation === "hold") {
return leftKeyframe.value;
}
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
const normalizedChannel = normalizeChannel({
channel,
});
const firstKey = normalizedChannel.keys[0];
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
if (!firstKey || !lastKey) {
return fallbackValue;
}
if (time <= firstKey.time) {
if (time < firstKey.time) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.before ?? "hold",
edgeKey: firstKey,
neighborKey: normalizedChannel.keys[1],
time,
});
}
return firstKey.value;
}
if (time >= lastKey.time) {
if (time > lastKey.time) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.after ?? "hold",
edgeKey: lastKey,
neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
time,
});
}
return lastKey.value;
}
for (
let keyIndex = 0;
keyIndex < normalizedChannel.keys.length - 1;
keyIndex++
) {
const leftKey = normalizedChannel.keys[keyIndex];
const rightKey = normalizedChannel.keys[keyIndex + 1];
if (time === rightKey.time) {
return rightKey.value;
}
if (
!isWithinTimePair({
time,
leftTime: leftKey.time,
rightTime: rightKey.time,
})
) {
continue;
}
if (leftKey.segmentToNext === "step") {
return leftKey.value;
}
const span = rightKey.time - leftKey.time;
if (span === 0) {
return rightKey.value;
}
const progress = clamp({
value: (time - leftKey.time) / span,
min: 0,
max: 1,
});
if (leftKey.segmentToNext === "linear") {
return lerpNumber({
leftValue: leftKeyframe.value,
rightValue: rightKeyframe.value,
leftValue: leftKey.value,
rightValue: rightKey.value,
progress,
});
},
});
}
const curveProgress = solveBezierProgressForTime({
time,
leftKey,
rightKey,
});
const rightHandle =
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
const leftHandle =
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
return getBezierPoint({
progress: curveProgress,
p0: leftKey.value,
p1: leftKey.value + rightHandle.dv,
p2: rightKey.value + leftHandle.dv,
p3: rightKey.value,
});
}
return lastKey.value;
}
export function getColorValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: ColorAnimationChannel | undefined;
time: number;
fallbackValue: string;
}): string {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
if (leftKeyframe.interpolation === "hold") {
return leftKeyframe.value;
}
return interpolateColor({
leftColor: leftKeyframe.value,
rightColor: rightKeyframe.value,
progress,
});
},
});
}
export function getVectorChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: VectorAnimationChannel | undefined;
time: number;
fallbackValue: VectorValue;
}): VectorValue {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
if (leftKeyframe.interpolation === "hold") {
return leftKeyframe.value;
}
return {
x:
leftKeyframe.value.x +
(rightKeyframe.value.x - leftKeyframe.value.x) * progress,
y:
leftKeyframe.value.y +
(rightKeyframe.value.y - leftKeyframe.value.y) * progress,
};
},
});
}
function getDiscreteValueAtTime({
export function getDiscreteChannelValueAtTime({
channel,
time,
fallbackValue,
@ -343,12 +323,21 @@ function getDiscreteValueAtTime({
time: number;
fallbackValue: DiscreteValue;
}): DiscreteValue {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
const normalizedChannel = normalizeChannel({
channel,
});
let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) {
if (time < key.time) {
break;
}
currentValue = key.value;
}
return currentValue;
}
export function getChannelValueAtTime({
@ -360,50 +349,25 @@ export function getChannelValueAtTime({
time: number;
fallbackValue: AnimationValue;
}): AnimationValue {
if (!channel || channel.keyframes.length === 0) {
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
if (channel.valueKind === "number") {
if (typeof fallbackValue !== "number") {
return fallbackValue;
}
return getNumberChannelValueAtTime({
channel,
time,
fallbackValue,
});
}
if (channel.valueKind === "color") {
if (typeof fallbackValue !== "string") {
return fallbackValue;
}
return getColorValueAtTime({
channel,
time,
fallbackValue,
});
}
if (channel.valueKind === "vector") {
if (!isVectorValue(fallbackValue)) {
return fallbackValue;
}
return getVectorChannelValueAtTime({
channel,
time,
fallbackValue: fallbackValue as VectorValue,
});
if (channel.kind === "scalar") {
return typeof fallbackValue === "number"
? getScalarChannelValueAtTime({
channel,
time,
fallbackValue,
})
: fallbackValue;
}
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue;
}
return getDiscreteValueAtTime({
return getDiscreteChannelValueAtTime({
channel,
time,
fallbackValue,

View File

@ -1,72 +1,294 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type {
AnimationPath,
ElementAnimations,
ElementKeyframe,
} from "@/lib/animation/types";
import { isAnimationPath } from "./target-resolver";
export function getElementKeyframes({
animations,
}: {
animations: ElementAnimations | undefined;
}): ElementKeyframe[] {
if (!animations) {
return [];
}
return Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
if (
!channel ||
channel.keyframes.length === 0 ||
!isAnimationPath(propertyPath)
) {
return [];
}
return channel.keyframes.map((keyframe) => ({
propertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
interpolation: keyframe.interpolation,
}));
},
);
}
export function hasKeyframesForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): boolean {
const channel = animations?.channels[propertyPath];
return Boolean(channel && channel.keyframes.length > 0);
}
export function getKeyframeAtTime({
animations,
propertyPath,
time,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
const channel = animations?.channels[propertyPath];
if (!channel || channel.keyframes.length === 0) return null;
const keyframe = channel.keyframes.find(
(keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
);
if (!keyframe) return null;
return {
propertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
interpolation: keyframe.interpolation,
};
}
import type {
AnimationBindingInstance,
AnimationChannel,
AnimationPath,
ElementAnimations,
ElementKeyframe,
} from "@/lib/animation/types";
import {
type AnimationComponentValue,
composeAnimationValue,
} from "./binding-values";
import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
} from "./interpolation";
import { isAnimationPath } from "./target-resolver";
function getBindingFallbackValue({
channel,
}: {
channel: ElementAnimations["channels"][string];
}) {
if (!channel || channel.keys.length === 0) {
return channel?.kind === "discrete" ? false : 0;
}
return channel.keys[0].value;
}
interface BindingKeyframeMatch {
componentIndex: number;
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}
function getBindingKeyframeMatches({
animations,
binding,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
}): BindingKeyframeMatch[] {
return binding.components.flatMap((component, componentIndex) => {
const channel = animations.channels[component.channelId];
if (!channel || channel.keys.length === 0) {
return [];
}
return channel.keys.map((keyframe) => ({
componentIndex,
channel,
keyframe,
}));
});
}
function getUniqueBindingKeyframeMatches({
animations,
binding,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
}): BindingKeyframeMatch[] {
const sortedMatches = getBindingKeyframeMatches({
animations,
binding,
}).sort(
(leftMatch, rightMatch) =>
leftMatch.keyframe.time - rightMatch.keyframe.time ||
leftMatch.componentIndex - rightMatch.componentIndex,
);
const uniqueMatches: BindingKeyframeMatch[] = [];
for (const match of sortedMatches) {
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
if (
!previousMatch ||
previousMatch.keyframe.time !== match.keyframe.time
) {
uniqueMatches.push(match);
continue;
}
if (
previousMatch.componentIndex !== 0 &&
match.componentIndex === 0
) {
uniqueMatches[uniqueMatches.length - 1] = match;
}
}
return uniqueMatches;
}
function getPreferredBindingKeyframeMatch({
matches,
}: {
matches: BindingKeyframeMatch[];
}): BindingKeyframeMatch | null {
return (
matches.find((match) => match.componentIndex === 0) ??
matches[0] ??
null
);
}
function getComposedBindingValueAtTime({
animations,
binding,
time,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
time: number;
}) {
const componentValues = Object.fromEntries(
binding.components.map((component) => {
const channel = animations.channels[component.channelId];
return [
component.key,
getChannelValueAtTime({
channel,
time,
fallbackValue: getBindingFallbackValue({ channel }),
}),
];
}),
) as Record<string, AnimationComponentValue | undefined>;
return composeAnimationValue({
binding,
componentValues,
});
}
function getKeyframeInterpolation({
channel,
keyframe,
}: {
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}) {
return channel.kind === "scalar" && "segmentToNext" in keyframe
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
: "hold";
}
function toElementKeyframe({
animations,
binding,
propertyPath,
keyframeMatch,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
propertyPath: AnimationPath;
keyframeMatch: BindingKeyframeMatch;
}): ElementKeyframe | null {
const value = getComposedBindingValueAtTime({
animations,
binding,
time: keyframeMatch.keyframe.time,
});
if (value === null) {
return null;
}
return {
propertyPath,
id: keyframeMatch.keyframe.id,
time: keyframeMatch.keyframe.time,
value,
interpolation: getKeyframeInterpolation({
channel: keyframeMatch.channel,
keyframe: keyframeMatch.keyframe,
}),
};
}
export function getElementKeyframes({
animations,
}: {
animations: ElementAnimations | undefined;
}): ElementKeyframe[] {
if (!animations) {
return [];
}
return Object.entries(animations.bindings).flatMap(
([propertyPath, binding]) => {
if (!binding || !isAnimationPath(propertyPath)) {
return [];
}
return getUniqueBindingKeyframeMatches({
animations,
binding,
}).flatMap((keyframeMatch) => {
const keyframe = toElementKeyframe({
animations,
binding,
propertyPath,
keyframeMatch,
});
if (!keyframe) {
return [];
}
return [keyframe];
});
},
);
}
export function hasKeyframesForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): boolean {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return false;
}
return binding.components.some((component) =>
Boolean(animations?.channels[component.channelId]?.keys.length),
);
}
export function getKeyframeAtTime({
animations,
propertyPath,
time,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return null;
}
const keyframeMatch = getPreferredBindingKeyframeMatch({
matches: getBindingKeyframeMatches({
animations,
binding,
}).filter(({ keyframe }) => keyframe.time === time),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
animations,
binding,
propertyPath,
keyframeMatch,
});
}
export function getKeyframeById({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
}): ElementKeyframe | null {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return null;
}
const keyframeMatch = getPreferredBindingKeyframeMatch({
matches: getBindingKeyframeMatches({
animations,
binding,
}).filter(({ keyframe }) => keyframe.id === keyframeId),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
animations,
binding,
propertyPath,
keyframeMatch,
});
}

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
import type {
AnimationPropertyPath,
ElementAnimations,
NumberAnimationChannel,
} from "@/lib/animation/types";
export function getNumberChannelForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
}): NumberAnimationChannel | undefined {
const channel = animations?.channels[propertyPath];
if (!channel || channel.valueKind !== "number") {
return undefined;
}
return channel;
}

View File

@ -1,369 +1,377 @@
import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
AnimationValueKind,
DiscreteValue,
VectorValue,
} from "@/lib/animation/types";
import { isVectorValue } from "./vector-channel";
import type { TimelineElement } from "@/lib/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
} from "@/constants/text-constants";
import {
canElementHaveAudio,
isVisualElement,
} from "@/lib/timeline/element-utils";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { snapToStep } from "@/utils/math";
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
export type NumericRange = NumericSpec;
export interface AnimationPropertyDefinition {
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
numericRange?: NumericSpec;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
setValue: ({
element,
value,
}: {
element: TimelineElement;
value: AnimationValue;
}) => TimelineElement;
}
const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
"transform.position": {
valueKind: "vector",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: value as VectorValue,
},
}
: element,
},
"transform.scaleX": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scaleX : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, scaleX: value as number },
}
: element,
},
"transform.scaleY": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scaleY : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, scaleY: value as number },
}
: element,
},
"transform.rotate": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: -360, max: 360, step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, rotate: value as number },
}
: element,
},
opacity: {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0, max: 1, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? { ...element, opacity: value as number }
: element,
},
volume: {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
supportsElement: ({ element }) => canElementHaveAudio(element),
getValue: ({ element }) =>
canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) =>
canElementHaveAudio(element)
? { ...element, volume: value as number }
: element,
},
color: {
valueKind: "color",
defaultInterpolation: "linear",
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) => (element.type === "text" ? element.color : null),
setValue: ({ element, value }) =>
element.type === "text"
? { ...element, color: value as string }
: element,
},
"background.color": {
valueKind: "color",
defaultInterpolation: "linear",
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text" ? element.background.color : null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, color: value as string },
}
: element,
},
"background.paddingX": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, paddingX: value as number },
}
: element,
},
"background.paddingY": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, paddingY: value as number },
}
: element,
},
"background.offsetX": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, offsetX: value as number },
}
: element,
},
"background.offsetY": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, offsetY: value as number },
}
: element,
},
"background.cornerRadius": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, cornerRadius: value as number },
}
: element,
},
};
export function isAnimationPropertyPath(
propertyPath: string,
): propertyPath is AnimationPropertyPath {
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
}
export function getAnimationPropertyDefinition({
propertyPath,
}: {
propertyPath: AnimationPropertyPath;
}): AnimationPropertyDefinition {
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
}
export function supportsAnimationProperty({
element,
propertyPath,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): boolean {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.supportsElement({ element });
}
export function getElementBaseValueForProperty({
element,
propertyPath,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): AnimationValue | null {
const definition = getAnimationPropertyDefinition({ propertyPath });
if (!definition.supportsElement({ element })) {
return null;
}
return definition.getValue({ element });
}
export function withElementBaseValueForProperty({
element,
propertyPath,
value,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): TimelineElement {
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
if (coercedValue === null) {
return element;
}
const definition = getAnimationPropertyDefinition({ propertyPath });
if (!definition.supportsElement({ element })) {
return element;
}
return definition.setValue({ element, value: coercedValue });
}
export function getDefaultInterpolationForProperty({
propertyPath,
}: {
propertyPath: AnimationPropertyPath;
}): AnimationInterpolation {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.defaultInterpolation;
}
function applyNumericSpec({
value,
numericRange,
}: {
value: number;
numericRange: NumericSpec | undefined;
}): number {
if (!numericRange) {
return value;
}
const steppedValue =
numericRange.step != null
? snapToStep({ value, step: numericRange.step })
: value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
export function coerceAnimationValueForProperty({
propertyPath,
value,
}: {
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): AnimationValue | null {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
if (propertyDefinition.valueKind === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
return applyNumericSpec({
value,
numericRange: propertyDefinition.numericRange,
});
}
if (propertyDefinition.valueKind === "color") {
return typeof value === "string" ? value : null;
}
if (propertyDefinition.valueKind === "vector") {
return isVectorValue(value) ? value : null;
}
if (typeof value === "string" || typeof value === "boolean") {
return value as DiscreteValue;
}
return null;
}
import type {
AnimationBindingKind,
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
VectorValue,
} from "@/lib/animation/types";
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
import type { TimelineElement } from "@/lib/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
} from "@/constants/text-constants";
import {
canElementHaveAudio,
isVisualElement,
} from "@/lib/timeline/element-utils";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { snapToStep } from "@/utils/math";
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
export interface AnimationPropertyDefinition {
kind: AnimationBindingKind;
defaultInterpolation: AnimationInterpolation;
numericRanges?: Partial<Record<string, NumericSpec>>;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
setValue: ({
element,
value,
}: {
element: TimelineElement;
value: AnimationValue;
}) => TimelineElement;
}
function applyNumericSpec({
value,
numericRange,
}: {
value: number;
numericRange: NumericSpec | undefined;
}): number {
if (!numericRange) {
return value;
}
const steppedValue =
numericRange.step != null
? snapToStep({ value, step: numericRange.step })
: value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
function coerceNumberValue({
value,
numericRange,
}: {
value: AnimationValue;
numericRange?: NumericSpec;
}): number | null {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
return applyNumericSpec({ value, numericRange });
}
function coerceColorValue({
value,
}: {
value: AnimationValue;
}): string | null {
return typeof value === "string" && parseColorToLinearRgba({ color: value })
? value
: null;
}
function createNumberPropertyDefinition({
numericRange,
supportsElement,
getValue,
setValue,
}: {
numericRange?: NumericSpec;
supportsElement: AnimationPropertyDefinition["supportsElement"];
getValue: AnimationPropertyDefinition["getValue"];
setValue: AnimationPropertyDefinition["setValue"];
}): AnimationPropertyDefinition {
return {
kind: "number",
defaultInterpolation: "linear",
numericRanges: numericRange ? { value: numericRange } : undefined,
supportsElement,
getValue,
coerceValue: ({ value }) =>
coerceNumberValue({
value,
numericRange,
}),
setValue,
};
}
const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
"transform.position": {
kind: "vector2",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position : null,
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: value as VectorValue,
},
}
: element,
},
"transform.scaleX": createNumberPropertyDefinition({
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scaleX : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, scaleX: value as number },
}
: element,
}),
"transform.scaleY": createNumberPropertyDefinition({
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scaleY : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, scaleY: value as number },
}
: element,
}),
"transform.rotate": createNumberPropertyDefinition({
numericRange: { min: -360, max: 360, step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, rotate: value as number },
}
: element,
}),
opacity: createNumberPropertyDefinition({
numericRange: { min: 0, max: 1, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? { ...element, opacity: value as number }
: element,
}),
volume: createNumberPropertyDefinition({
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
supportsElement: ({ element }) => canElementHaveAudio(element),
getValue: ({ element }) =>
canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) =>
canElementHaveAudio(element)
? { ...element, volume: value as number }
: element,
}),
color: {
kind: "color",
defaultInterpolation: "linear",
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) => (element.type === "text" ? element.color : null),
coerceValue: ({ value }) => coerceColorValue({ value }),
setValue: ({ element, value }) =>
element.type === "text"
? { ...element, color: value as string }
: element,
},
"background.color": {
kind: "color",
defaultInterpolation: "linear",
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text" ? element.background.color : null,
coerceValue: ({ value }) => coerceColorValue({ value }),
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, color: value as string },
}
: element,
},
"background.paddingX": createNumberPropertyDefinition({
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, paddingX: value as number },
}
: element,
}),
"background.paddingY": createNumberPropertyDefinition({
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, paddingY: value as number },
}
: element,
}),
"background.offsetX": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, offsetX: value as number },
}
: element,
}),
"background.offsetY": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, offsetY: value as number },
}
: element,
}),
"background.cornerRadius": createNumberPropertyDefinition({
numericRange: {
min: CORNER_RADIUS_MIN,
max: CORNER_RADIUS_MAX,
step: 1,
},
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, cornerRadius: value as number },
}
: element,
}),
};
export function isAnimationPropertyPath(
propertyPath: string,
): propertyPath is AnimationPropertyPath {
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
}
export function getAnimationPropertyDefinition({
propertyPath,
}: {
propertyPath: AnimationPropertyPath;
}): AnimationPropertyDefinition {
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
}
export function supportsAnimationProperty({
element,
propertyPath,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): boolean {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.supportsElement({ element });
}
export function getElementBaseValueForProperty({
element,
propertyPath,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): AnimationValue | null {
const definition = getAnimationPropertyDefinition({ propertyPath });
if (!definition.supportsElement({ element })) {
return null;
}
return definition.getValue({ element });
}
export function withElementBaseValueForProperty({
element,
propertyPath,
value,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): TimelineElement {
const definition = getAnimationPropertyDefinition({ propertyPath });
const coercedValue = definition.coerceValue({ value });
if (coercedValue === null || !definition.supportsElement({ element })) {
return element;
}
return definition.setValue({ element, value: coercedValue });
}
export function getDefaultInterpolationForProperty({
propertyPath,
}: {
propertyPath: AnimationPropertyPath;
}): AnimationInterpolation {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.defaultInterpolation;
}
export function coerceAnimationValueForProperty({
propertyPath,
value,
}: {
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): AnimationValue | null {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.coerceValue({ value });
}

View File

@ -1,135 +1,176 @@
import type {
AnimationPropertyPath,
ElementAnimations,
} from "@/lib/animation/types";
import type { Transform } from "@/lib/rendering";
import {
getColorValueAtTime,
getNumberChannelValueAtTime,
getVectorChannelValueAtTime,
} from "./interpolation";
import { getColorChannelForPath } from "./color-channel";
import { getNumberChannelForPath } from "./number-channel";
import { getVectorChannelForPath } from "./vector-channel";
export function getElementLocalTime({
timelineTime,
elementStartTime,
elementDuration,
}: {
timelineTime: number;
elementStartTime: number;
elementDuration: number;
}): number {
const localTime = timelineTime - elementStartTime;
if (localTime <= 0) {
return 0;
}
if (localTime >= elementDuration) {
return elementDuration;
}
return localTime;
}
export function resolveTransformAtTime({
baseTransform,
animations,
localTime,
}: {
baseTransform: Transform;
animations: ElementAnimations | undefined;
localTime: number;
}): Transform {
const safeLocalTime = Math.max(0, localTime);
return {
position: getVectorChannelValueAtTime({
channel: getVectorChannelForPath({
animations,
propertyPath: "transform.position",
}),
time: safeLocalTime,
fallbackValue: baseTransform.position,
}),
scaleX: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "transform.scaleX",
}),
time: safeLocalTime,
fallbackValue: baseTransform.scaleX,
}),
scaleY: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "transform.scaleY",
}),
time: safeLocalTime,
fallbackValue: baseTransform.scaleY,
}),
rotate: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "transform.rotate",
}),
time: safeLocalTime,
fallbackValue: baseTransform.rotate,
}),
};
}
export function resolveOpacityAtTime({
baseOpacity,
animations,
localTime,
}: {
baseOpacity: number;
animations: ElementAnimations | undefined;
localTime: number;
}): number {
return getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "opacity",
}),
time: Math.max(0, localTime),
fallbackValue: baseOpacity,
});
}
export function resolveNumberAtTime({
baseValue,
animations,
propertyPath,
localTime,
}: {
baseValue: number;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
}): number {
return getNumberChannelValueAtTime({
channel: getNumberChannelForPath({ animations, propertyPath }),
time: Math.max(0, localTime),
fallbackValue: baseValue,
});
}
export function resolveColorAtTime({
baseColor,
animations,
propertyPath,
localTime,
}: {
baseColor: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
}): string {
return getColorValueAtTime({
channel: getColorChannelForPath({ animations, propertyPath }),
time: Math.max(0, localTime),
fallbackValue: baseColor,
});
}
import type {
AnimationColorPropertyPath,
AnimationNumericPropertyPath,
AnimationPath,
AnimationPropertyPath,
AnimationValueForPath,
ElementAnimations,
} from "@/lib/animation/types";
import type { Transform } from "@/lib/rendering";
import {
type AnimationComponentValue,
composeAnimationValue,
decomposeAnimationValue,
} from "./binding-values";
import {
getChannelValueAtTime,
} from "./interpolation";
export function getElementLocalTime({
timelineTime,
elementStartTime,
elementDuration,
}: {
timelineTime: number;
elementStartTime: number;
elementDuration: number;
}): number {
const localTime = timelineTime - elementStartTime;
if (localTime <= 0) {
return 0;
}
if (localTime >= elementDuration) {
return elementDuration;
}
return localTime;
}
export function resolveTransformAtTime({
baseTransform,
animations,
localTime,
}: {
baseTransform: Transform;
animations: ElementAnimations | undefined;
localTime: number;
}): Transform {
const safeLocalTime = Math.max(0, localTime);
return {
position: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.position",
localTime: safeLocalTime,
fallbackValue: baseTransform.position,
}),
scaleX: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.scaleX",
localTime: safeLocalTime,
fallbackValue: baseTransform.scaleX,
}),
scaleY: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.scaleY",
localTime: safeLocalTime,
fallbackValue: baseTransform.scaleY,
}),
rotate: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.rotate",
localTime: safeLocalTime,
fallbackValue: baseTransform.rotate,
}),
};
}
export function resolveOpacityAtTime({
baseOpacity,
animations,
localTime,
}: {
baseOpacity: number;
animations: ElementAnimations | undefined;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath: "opacity",
localTime: Math.max(0, localTime),
fallbackValue: baseOpacity,
});
}
export function resolveNumberAtTime({
baseValue,
animations,
propertyPath,
localTime,
}: {
baseValue: number;
animations: ElementAnimations | undefined;
propertyPath: AnimationNumericPropertyPath;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseValue,
});
}
export function resolveColorAtTime({
baseColor,
animations,
propertyPath,
localTime,
}: {
baseColor: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationColorPropertyPath;
localTime: number;
}): string {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseColor,
});
}
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: TPath;
localTime: number;
fallbackValue: AnimationValueForPath<TPath>;
}): AnimationValueForPath<TPath> {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return fallbackValue;
}
const fallbackComponents = decomposeAnimationValue({
kind: binding.kind,
value: fallbackValue,
});
if (!fallbackComponents) {
return fallbackValue;
}
const componentValues = Object.fromEntries(
binding.components.map((component) => {
const channel = animations?.channels[component.channelId];
return [
component.key,
getChannelValueAtTime({
channel,
time: localTime,
fallbackValue:
fallbackComponents[component.key] ??
(channel?.kind === "discrete" ? false : 0),
}),
];
}),
) as Record<string, AnimationComponentValue | undefined>;
return (composeAnimationValue({
binding,
componentValues,
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
}

View File

@ -1,277 +1,285 @@
import type {
AnimationInterpolation,
AnimationPath,
AnimationValue,
AnimationValueKind,
} from "@/lib/animation/types";
import {
parseEffectParamPath,
isEffectParamPath,
} from "@/lib/animation/effect-param-channel";
import {
isGraphicParamPath,
parseGraphicParamPath,
} from "@/lib/animation/graphic-param-channel";
import type { ParamDefinition } from "@/lib/params";
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
import { getGraphicDefinition } from "@/lib/graphics";
import type { TimelineElement } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
import { snapToStep } from "@/utils/math";
import {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getElementBaseValueForProperty,
isAnimationPropertyPath,
type NumericSpec,
withElementBaseValueForProperty,
} from "./property-registry";
export interface AnimationPathDescriptor {
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
numericRange?: NumericSpec;
getBaseValue(): AnimationValue | null;
setBaseValue(value: AnimationValue): TimelineElement;
}
export function getParamValueKind({
param,
}: {
param: ParamDefinition;
}): AnimationValueKind {
if (param.type === "number") {
return "number";
}
if (param.type === "color") {
return "color";
}
return "discrete";
}
export function getParamDefaultInterpolation({
param,
}: {
param: ParamDefinition;
}): AnimationInterpolation {
return param.type === "number" || param.type === "color" ? "linear" : "hold";
}
function getParamNumericRange({
param,
}: {
param: ParamDefinition;
}): NumericSpec | undefined {
if (param.type !== "number") {
return undefined;
}
return {
min: param.min,
max: param.max,
step: param.step,
};
}
function coerceParamValue({
param,
value,
}: {
param: ParamDefinition;
value: AnimationValue;
}): number | string | boolean | null {
if (param.type === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
const steppedValue = snapToStep({ value, step: param.step });
const minValue = param.min;
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
if (param.type === "color") {
return typeof value === "string" ? value : null;
}
if (param.type === "boolean") {
return typeof value === "boolean" ? value : null;
}
if (typeof value !== "string") {
return null;
}
return param.options.some((option) => option.value === value) ? value : null;
}
function buildGraphicParamDescriptor({
element,
paramKey,
}: {
element: TimelineElement;
paramKey: string;
}): AnimationPathDescriptor | null {
if (element.type !== "graphic") {
return null;
}
const definition = getGraphicDefinition({
definitionId: element.definitionId,
});
const param = definition.params.find((candidate) => candidate.key === paramKey);
if (!param) {
return null;
}
return {
valueKind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRange: getParamNumericRange({ param }),
getBaseValue: () => element.params[param.key] ?? param.default,
setBaseValue: (value) => {
const coercedValue = coerceParamValue({ param, value });
if (coercedValue === null) {
return element;
}
return {
...element,
params: {
...element.params,
[param.key]: coercedValue,
},
};
},
};
}
function buildEffectParamDescriptor({
element,
effectId,
paramKey,
}: {
element: TimelineElement;
effectId: string;
paramKey: string;
}): AnimationPathDescriptor | null {
if (!isVisualElement(element)) {
return null;
}
const effect = element.effects?.find((candidate) => candidate.id === effectId);
if (!effect) {
return null;
}
registerDefaultEffects();
const definition = effectsRegistry.get(effect.type);
const param = definition.params.find((candidate) => candidate.key === paramKey);
if (!param) {
return null;
}
return {
valueKind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRange: getParamNumericRange({ param }),
getBaseValue: () => effect.params[param.key] ?? param.default,
setBaseValue: (value) => {
const coercedValue = coerceParamValue({ param, value });
if (coercedValue === null) {
return element;
}
return {
...element,
effects:
element.effects?.map((candidate) =>
candidate.id !== effectId
? candidate
: {
...candidate,
params: {
...candidate.params,
[param.key]: coercedValue,
},
},
) ?? element.effects,
};
},
};
}
export function isAnimationPath(
propertyPath: string,
): propertyPath is AnimationPath {
return (
isAnimationPropertyPath(propertyPath) ||
isGraphicParamPath(propertyPath) ||
isEffectParamPath(propertyPath)
);
}
export function resolveAnimationTarget({
element,
path,
}: {
element: TimelineElement;
path: AnimationPath;
}): AnimationPathDescriptor | null {
if (isAnimationPropertyPath(path)) {
const propertyDefinition = getAnimationPropertyDefinition({
propertyPath: path,
});
if (!propertyDefinition.supportsElement({ element })) {
return null;
}
return {
valueKind: propertyDefinition.valueKind,
defaultInterpolation: propertyDefinition.defaultInterpolation,
numericRange: propertyDefinition.numericRange,
getBaseValue: () =>
getElementBaseValueForProperty({
element,
propertyPath: path,
}),
setBaseValue: (value) => {
const coercedValue = coerceAnimationValueForProperty({
propertyPath: path,
value,
});
if (coercedValue === null) {
return element;
}
return withElementBaseValueForProperty({
element,
propertyPath: path,
value: coercedValue,
});
},
};
}
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
if (graphicParamTarget) {
return buildGraphicParamDescriptor({
element,
paramKey: graphicParamTarget.paramKey,
});
}
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
if (effectParamTarget) {
return buildEffectParamDescriptor({
element,
effectId: effectParamTarget.effectId,
paramKey: effectParamTarget.paramKey,
});
}
return null;
}
import type {
AnimationBindingKind,
AnimationInterpolation,
AnimationPath,
AnimationValue,
} from "@/lib/animation/types";
import {
parseEffectParamPath,
isEffectParamPath,
} from "@/lib/animation/effect-param-channel";
import {
isGraphicParamPath,
parseGraphicParamPath,
} from "@/lib/animation/graphic-param-channel";
import type { ParamDefinition } from "@/lib/params";
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
import { getGraphicDefinition } from "@/lib/graphics";
import type { TimelineElement } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
import { snapToStep } from "@/utils/math";
import {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getElementBaseValueForProperty,
isAnimationPropertyPath,
type NumericSpec,
withElementBaseValueForProperty,
} from "./property-registry";
import { parseColorToLinearRgba } from "./binding-values";
export interface AnimationPathDescriptor {
kind: AnimationBindingKind;
defaultInterpolation: AnimationInterpolation;
numericRanges?: Partial<Record<string, NumericSpec>>;
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
getBaseValue: () => AnimationValue | null;
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
}
export function getParamValueKind({
param,
}: {
param: ParamDefinition;
}): AnimationBindingKind {
if (param.type === "number") {
return "number";
}
if (param.type === "color") {
return "color";
}
return "discrete";
}
export function getParamDefaultInterpolation({
param,
}: {
param: ParamDefinition;
}): AnimationInterpolation {
return param.type === "number" || param.type === "color" ? "linear" : "hold";
}
function getParamNumericRange({
param,
}: {
param: ParamDefinition;
}): Partial<Record<string, NumericSpec>> | undefined {
if (param.type !== "number") {
return undefined;
}
return {
value: {
min: param.min,
max: param.max,
step: param.step,
},
};
}
export function coerceAnimationValueForParam({
param,
value,
}: {
param: ParamDefinition;
value: AnimationValue;
}): number | string | boolean | null {
if (param.type === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
const steppedValue = snapToStep({ value, step: param.step });
const minValue = param.min;
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
if (param.type === "color") {
return typeof value === "string" ? value : null;
}
if (param.type === "boolean") {
return typeof value === "boolean" ? value : null;
}
if (typeof value !== "string") {
return null;
}
return param.options.some((option) => option.value === value) ? value : null;
}
function buildGraphicParamDescriptor({
element,
paramKey,
}: {
element: TimelineElement;
paramKey: string;
}): AnimationPathDescriptor | null {
if (element.type !== "graphic") {
return null;
}
const definition = getGraphicDefinition({
definitionId: element.definitionId,
});
const param = definition.params.find((candidate) => candidate.key === paramKey);
if (!param) {
return null;
}
return {
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: getParamNumericRange({ param }),
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
getBaseValue: () => element.params[param.key] ?? param.default,
setBaseValue: ({ value }) => {
const coercedValue = coerceAnimationValueForParam({ param, value });
if (coercedValue === null) {
return element;
}
return {
...element,
params: {
...element.params,
[param.key]: coercedValue,
},
};
},
};
}
function buildEffectParamDescriptor({
element,
effectId,
paramKey,
}: {
element: TimelineElement;
effectId: string;
paramKey: string;
}): AnimationPathDescriptor | null {
if (!isVisualElement(element)) {
return null;
}
const effect = element.effects?.find((candidate) => candidate.id === effectId);
if (!effect) {
return null;
}
registerDefaultEffects();
const definition = effectsRegistry.get(effect.type);
const param = definition.params.find((candidate) => candidate.key === paramKey);
if (!param) {
return null;
}
return {
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: getParamNumericRange({ param }),
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
getBaseValue: () => effect.params[param.key] ?? param.default,
setBaseValue: ({ value }) => {
const coercedValue = coerceAnimationValueForParam({ param, value });
if (coercedValue === null) {
return element;
}
return {
...element,
effects:
element.effects?.map((candidate) =>
candidate.id !== effectId
? candidate
: {
...candidate,
params: {
...candidate.params,
[param.key]: coercedValue,
},
},
) ?? element.effects,
};
},
};
}
export function isAnimationPath(
propertyPath: string,
): propertyPath is AnimationPath {
return (
isAnimationPropertyPath(propertyPath) ||
isGraphicParamPath(propertyPath) ||
isEffectParamPath(propertyPath)
);
}
export function resolveAnimationTarget({
element,
path,
}: {
element: TimelineElement;
path: AnimationPath;
}): AnimationPathDescriptor | null {
if (isAnimationPropertyPath(path)) {
const propertyDefinition = getAnimationPropertyDefinition({
propertyPath: path,
});
if (!propertyDefinition.supportsElement({ element })) {
return null;
}
return {
kind: propertyDefinition.kind,
defaultInterpolation: propertyDefinition.defaultInterpolation,
numericRanges: propertyDefinition.numericRanges,
coerceValue: ({ value }) =>
coerceAnimationValueForProperty({
propertyPath: path,
value,
}),
getBaseValue: () =>
getElementBaseValueForProperty({
element,
propertyPath: path,
}),
setBaseValue: ({ value }) => {
const coercedValue = propertyDefinition.coerceValue({ value });
if (coercedValue === null) {
return element;
}
return withElementBaseValueForProperty({
element,
propertyPath: path,
value: coercedValue,
});
},
};
}
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
if (graphicParamTarget) {
return buildGraphicParamDescriptor({
element,
paramKey: graphicParamTarget.paramKey,
});
}
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
if (effectParamTarget) {
return buildEffectParamDescriptor({
element,
effectId: effectParamTarget.effectId,
paramKey: effectParamTarget.paramKey,
});
}
return null;
}

View File

@ -1,119 +1,220 @@
export const ANIMATION_PROPERTY_PATHS = [
"transform.position",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
"opacity",
"volume",
"color",
"background.color",
"background.paddingX",
"background.paddingY",
"background.offsetX",
"background.offsetY",
"background.cornerRadius",
] as const;
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`;
export type AnimationPath =
| AnimationPropertyPath
| GraphicParamPath
| EffectParamPath;
export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"],
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
export type VectorValue = { x: number; y: number };
export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue;
export type ContinuousKeyframeInterpolation = "linear" | "hold";
export type DiscreteKeyframeInterpolation = "hold";
export type AnimationInterpolation =
| ContinuousKeyframeInterpolation
| DiscreteKeyframeInterpolation;
interface BaseAnimationKeyframe<
TValue extends AnimationValue,
TInterpolation extends AnimationInterpolation,
> {
id: string;
time: number; // relative to element start time
value: TValue;
interpolation: TInterpolation;
}
export interface NumberKeyframe
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
export interface ColorKeyframe
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
export interface DiscreteKeyframe
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
export interface VectorKeyframe
extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
export type AnimationKeyframe =
| NumberKeyframe
| ColorKeyframe
| DiscreteKeyframe
| VectorKeyframe;
interface BaseAnimationChannel<
TValueKind extends AnimationValueKind,
TKeyframe extends AnimationKeyframe,
> {
valueKind: TValueKind;
keyframes: TKeyframe[];
}
export interface NumberAnimationChannel
extends BaseAnimationChannel<"number", NumberKeyframe> {}
export interface ColorAnimationChannel
extends BaseAnimationChannel<"color", ColorKeyframe> {}
export interface DiscreteAnimationChannel
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
export interface VectorAnimationChannel
extends BaseAnimationChannel<"vector", VectorKeyframe> {}
export type AnimationChannel =
| NumberAnimationChannel
| ColorAnimationChannel
| DiscreteAnimationChannel
| VectorAnimationChannel;
export type ElementAnimationChannelMap = Record<
string,
AnimationChannel | undefined
>;
export interface ElementAnimations {
channels: ElementAnimationChannelMap;
}
export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
time: number;
value: AnimationValue;
interpolation: AnimationInterpolation;
}
export interface SelectedKeyframeRef {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
}
import type { ParamValues } from "@/lib/params";
export const ANIMATION_PROPERTY_PATHS = [
"transform.position",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
"opacity",
"volume",
"color",
"background.color",
"background.paddingX",
"background.paddingY",
"background.offsetX",
"background.offsetY",
"background.cornerRadius",
] as const;
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`;
export type AnimationPath =
| AnimationPropertyPath
| GraphicParamPath
| EffectParamPath;
export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"],
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue;
export interface AnimationPropertyValueMap {
"transform.position": VectorValue;
"transform.scaleX": number;
"transform.scaleY": number;
"transform.rotate": number;
opacity: number;
volume: number;
color: string;
"background.color": string;
"background.paddingX": number;
"background.paddingY": number;
"background.offsetX": number;
"background.offsetY": number;
"background.cornerRadius": number;
}
export type DynamicAnimationPathValue = ParamValues[string];
export type AnimationValueForPath<TPath extends AnimationPath> =
TPath extends AnimationPropertyPath
? AnimationPropertyValueMap[TPath]
: TPath extends GraphicParamPath | EffectParamPath
? DynamicAnimationPathValue
: never;
export type AnimationNumericPropertyPath = {
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
}[AnimationPropertyPath];
export type AnimationColorPropertyPath = {
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
}[AnimationPropertyPath];
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold";
export type AnimationInterpolation =
| ContinuousKeyframeInterpolation
| DiscreteKeyframeInterpolation;
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
export type ScalarSegmentType = "step" | "linear" | "bezier";
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
export type ChannelExtrapolationMode = "hold" | "linear";
export interface CurveHandle {
dt: number;
dv: number;
}
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
id: string;
time: number; // relative to element start time
value: TValue;
}
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
leftHandle?: CurveHandle;
rightHandle?: CurveHandle;
segmentToNext: ScalarSegmentType;
tangentMode: TangentMode;
}
export interface DiscreteAnimationKey
extends BaseAnimationKeyframe<DiscreteValue> {}
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
export interface ScalarAnimationChannel {
kind: "scalar";
keys: ScalarAnimationKey[];
extrapolation?: {
before: ChannelExtrapolationMode;
after: ChannelExtrapolationMode;
};
}
export interface DiscreteAnimationChannel {
kind: "discrete";
keys: DiscreteAnimationKey[];
}
export type AnimationChannel =
| ScalarAnimationChannel
| DiscreteAnimationChannel;
export type ElementAnimationChannelMap = Record<
string,
AnimationChannel | undefined
>;
export interface AnimationBindingComponent<TKey extends string = string> {
key: TKey;
channelId: string;
}
interface BaseAnimationBinding<
TKind extends AnimationBindingKind,
TComponentKey extends string,
> {
path: AnimationPath;
kind: TKind;
components: AnimationBindingComponent<TComponentKey>[];
}
export interface NumberAnimationBinding
extends BaseAnimationBinding<"number", "value"> {}
export interface Vector2AnimationBinding
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
export interface ColorAnimationBinding
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
colorSpace: "srgb-linear";
}
export interface DiscreteAnimationBinding
extends BaseAnimationBinding<"discrete", "value"> {}
export type AnimationBindingInstance =
| NumberAnimationBinding
| Vector2AnimationBinding
| ColorAnimationBinding
| DiscreteAnimationBinding;
export interface AnimationBindingByKind {
number: NumberAnimationBinding;
vector2: Vector2AnimationBinding;
color: ColorAnimationBinding;
discrete: DiscreteAnimationBinding;
}
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
AnimationBindingByKind[TKind];
export type ElementAnimationBindingMap = Record<
string,
AnimationBindingInstance | undefined
>;
export interface ElementAnimations {
bindings: ElementAnimationBindingMap;
channels: ElementAnimationChannelMap;
}
export type NormalizedCubicBezier = [number, number, number, number];
export interface ScalarGraphChannelTarget {
propertyPath: AnimationPath;
componentKey: string;
channelId: string;
}
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
channel: ScalarAnimationChannel;
}
export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
keyframeId: string;
}
export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
keyframe: ScalarAnimationKey;
keyframeIndex: number;
previousKey: ScalarAnimationKey | null;
nextKey: ScalarAnimationKey | null;
}
export interface ScalarCurveKeyframePatch {
leftHandle?: CurveHandle | null;
rightHandle?: CurveHandle | null;
segmentToNext?: ScalarSegmentType;
tangentMode?: TangentMode;
}
export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
time: number;
value: AnimationValue;
interpolation: AnimationInterpolation;
}
export interface SelectedKeyframeRef {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
}

View File

@ -1,72 +0,0 @@
import type {
AnimationPropertyPath,
ElementAnimations,
VectorAnimationChannel,
VectorValue,
} from "@/lib/animation/types";
export function isVectorValue(value: unknown): value is VectorValue {
return (
typeof value === "object" &&
value !== null &&
"x" in value &&
"y" in value &&
typeof (value as VectorValue).x === "number" &&
typeof (value as VectorValue).y === "number"
);
}
export function getVectorChannelForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
}): VectorAnimationChannel | undefined {
const channel = animations?.channels[propertyPath];
if (!channel || channel.valueKind !== "vector") {
return undefined;
}
return channel;
}
export function getVectorChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: VectorAnimationChannel | undefined;
time: number;
fallbackValue: VectorValue;
}): VectorValue {
if (!channel || channel.keyframes.length === 0) {
return fallbackValue;
}
const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time);
const first = keyframes[0];
const last = keyframes[keyframes.length - 1];
if (!first || !last) return fallbackValue;
if (time <= first.time) return first.value;
if (time >= last.time) return last.value;
for (let i = 0; i < keyframes.length - 1; i++) {
const left = keyframes[i];
const right = keyframes[i + 1];
if (time < left.time || time > right.time) continue;
if (left.interpolation === "hold") return left.value;
const span = right.time - left.time;
if (span === 0) return right.value;
const t = (time - left.time) / span;
return {
x: left.value.x + (right.value.x - left.value.x) * t,
y: left.value.y + (right.value.y - left.value.y) * t,
};
}
return last.value;
}

View File

@ -1,19 +1,20 @@
import { Command } from "@/lib/commands/base-command";
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { toast } from "sonner";
import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
import type { FrameRate } from "opencut-wasm";
import { hasMediaId } from "@/lib/timeline/element-utils";
import { getHighestImportedVideoFps } from "@/lib/fps/utils";
import { frameRatesEqual, getHighestImportedVideoFps } from "@/lib/fps/utils";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
export class AddMediaAssetCommand extends Command {
private assetId: string;
private savedAssets: MediaAsset[] | null = null;
private createdAsset: MediaAsset | null = null;
private previousProjectFps: number | null = null;
private appliedProjectFps: number | null = null;
private previousProjectFps: FrameRate | null = null;
private appliedProjectFps: FrameRate | null = null;
constructor(
private projectId: string,
@ -23,7 +24,7 @@ export class AddMediaAssetCommand extends Command {
this.assetId = generateUUID();
}
execute(): void {
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedAssets = [...editor.media.getAssets()];
@ -53,11 +54,15 @@ export class AddMediaAssetCommand extends Command {
assets: currentAssets.filter((asset) => asset.id !== this.assetId),
});
const currentTracks = editor.timeline.getTracks();
const currentTracks = editor.scenes.getActiveScene().tracks;
const orphanedElements: Array<{ trackId: string; elementId: string }> =
[];
for (const track of currentTracks) {
for (const track of [
...currentTracks.overlay,
currentTracks.main,
...currentTracks.audio,
]) {
for (const element of track.elements) {
if (hasMediaId(element) && element.mediaId === this.assetId) {
orphanedElements.push({
@ -80,6 +85,8 @@ export class AddMediaAssetCommand extends Command {
});
}
});
return undefined;
}
undo(): void {
@ -110,14 +117,15 @@ export class AddMediaAssetCommand extends Command {
const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return;
if (activeProject.settings.fps !== this.appliedProjectFps) return;
if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
const highestRemainingVideoFps = getHighestImportedVideoFps({
mediaAssets: editor.media.getAssets(),
});
const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator;
if (
highestRemainingVideoFps !== null &&
highestRemainingVideoFps >= this.appliedProjectFps
highestRemainingVideoFps >= appliedFpsFloat
) {
return;
}

View File

@ -1,99 +1,103 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/lib/timeline";
export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null;
private savedTracks: TimelineTrack[] | null = null;
private removedAsset: MediaAsset | null = null;
constructor(
private projectId: string,
private assetId: string,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const assets = editor.media.getAssets();
this.savedAssets = [...assets];
this.savedTracks = editor.timeline.getTracks();
this.removedAsset =
assets.find((media) => media.id === this.assetId) ?? null;
if (!this.removedAsset) {
console.error("Media asset not found:", this.assetId);
return;
}
if (this.removedAsset.url) {
URL.revokeObjectURL(this.removedAsset.url);
}
if (this.removedAsset.thumbnailUrl) {
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
}
videoCache.clearVideo({ mediaId: this.assetId });
editor.media.setAssets({
assets: assets.filter((media) => media.id !== this.assetId),
});
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
for (const track of this.savedTracks) {
for (const element of track.elements) {
if (hasMediaId(element) && element.mediaId === this.assetId) {
elementsToRemove.push({ trackId: track.id, elementId: element.id });
}
}
}
if (elementsToRemove.length > 0) {
editor.timeline.deleteElements({ elements: elementsToRemove });
}
storageService
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
.catch((error) => {
console.error("Failed to delete media item:", error);
});
}
undo(): void {
const editor = EditorCore.getInstance();
if (this.savedAssets && this.removedAsset) {
const restoredAsset: MediaAsset = {
...this.removedAsset,
url: URL.createObjectURL(this.removedAsset.file),
};
editor.media.setAssets({
assets: this.savedAssets.map((a) =>
a.id === this.assetId ? restoredAsset : a,
),
});
storageService
.saveMediaAsset({
projectId: this.projectId,
mediaAsset: restoredAsset,
})
.catch((error) => {
console.error("Failed to restore media item on undo:", error);
});
}
if (this.savedTracks) {
editor.timeline.updateTracks(this.savedTracks);
}
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
import type { SceneTracks } from "@/lib/timeline";
export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null;
private savedTracks: SceneTracks | null = null;
private removedAsset: MediaAsset | null = null;
constructor(
private projectId: string,
private assetId: string,
) {
super();
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const assets = editor.media.getAssets();
this.savedAssets = [...assets];
this.savedTracks = editor.scenes.getActiveScene().tracks;
this.removedAsset =
assets.find((media) => media.id === this.assetId) ?? null;
if (!this.removedAsset) {
console.error("Media asset not found:", this.assetId);
return;
}
if (this.removedAsset.url) {
URL.revokeObjectURL(this.removedAsset.url);
}
if (this.removedAsset.thumbnailUrl) {
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
}
videoCache.clearVideo({ mediaId: this.assetId });
editor.media.setAssets({
assets: assets.filter((media) => media.id !== this.assetId),
});
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
for (const track of [
...this.savedTracks.overlay,
this.savedTracks.main,
...this.savedTracks.audio,
]) {
for (const element of track.elements) {
if (hasMediaId(element) && element.mediaId === this.assetId) {
elementsToRemove.push({ trackId: track.id, elementId: element.id });
}
}
}
if (elementsToRemove.length > 0) {
editor.timeline.deleteElements({ elements: elementsToRemove });
}
storageService
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
.catch((error) => {
console.error("Failed to delete media item:", error);
});
}
undo(): void {
const editor = EditorCore.getInstance();
if (this.savedAssets && this.removedAsset) {
const restoredAsset: MediaAsset = {
...this.removedAsset,
url: URL.createObjectURL(this.removedAsset.file),
};
editor.media.setAssets({
assets: this.savedAssets.map((a) =>
a.id === this.assetId ? restoredAsset : a,
),
});
storageService
.saveMediaAsset({
projectId: this.projectId,
mediaAsset: restoredAsset,
})
.catch((error) => {
console.error("Failed to restore media item on undo:", error);
});
}
if (this.savedTracks) {
editor.timeline.updateTracks(this.savedTracks);
}
}
}

View File

@ -1,23 +1,23 @@
export class PreviewTracker<T> {
private snapshot: T | null = null;
begin({ state }: { state: T }): void {
if (this.snapshot === null) {
this.snapshot = structuredClone(state);
}
}
isActive(): boolean {
return this.snapshot !== null;
}
getSnapshot(): T | null {
return this.snapshot;
}
end(): T | null {
const snapshot = this.snapshot;
this.snapshot = null;
return snapshot;
}
}
export class PreviewTracker<T> {
private snapshot: T | null = null;
begin({ state }: { state: T }): void {
if (this.snapshot === null) {
this.snapshot = structuredClone(state);
}
}
isActive(): boolean {
return this.snapshot !== null;
}
getSnapshot(): T | null {
return this.snapshot;
}
end(): T | null {
const snapshot = this.snapshot;
this.snapshot = null;
return snapshot;
}
}

View File

@ -1,46 +1,46 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TProject, TProjectSettings } from "@/lib/project/types";
export class UpdateProjectSettingsCommand extends Command {
private savedSettings: TProjectSettings | null = null;
private savedUpdatedAt: Date | null = null;
constructor(private updates: Partial<TProjectSettings>) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
if (!activeProject) return;
this.savedSettings = activeProject.settings;
this.savedUpdatedAt = activeProject.metadata.updatedAt;
const updatedProject: TProject = {
...activeProject,
settings: { ...activeProject.settings, ...this.updates },
metadata: { ...activeProject.metadata, updatedAt: new Date() },
};
editor.project.setActiveProject({ project: updatedProject });
editor.save.markDirty();
}
undo(): void {
if (!this.savedSettings || !this.savedUpdatedAt) return;
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
if (!activeProject) return;
const updatedProject: TProject = {
...activeProject,
settings: this.savedSettings,
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
};
editor.project.setActiveProject({ project: updatedProject });
editor.save.markDirty();
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TProject, TProjectSettings } from "@/lib/project/types";
export class UpdateProjectSettingsCommand extends Command {
private savedSettings: TProjectSettings | null = null;
private savedUpdatedAt: Date | null = null;
constructor(private updates: Partial<TProjectSettings>) {
super();
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
if (!activeProject) return;
this.savedSettings = activeProject.settings;
this.savedUpdatedAt = activeProject.metadata.updatedAt;
const updatedProject: TProject = {
...activeProject,
settings: { ...activeProject.settings, ...this.updates },
metadata: { ...activeProject.metadata, updatedAt: new Date() },
};
editor.project.setActiveProject({ project: updatedProject });
editor.save.markDirty();
}
undo(): void {
if (!this.savedSettings || !this.savedUpdatedAt) return;
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
if (!activeProject) return;
const updatedProject: TProject = {
...activeProject,
settings: this.savedSettings,
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
};
editor.project.setActiveProject({ project: updatedProject });
editor.save.markDirty();
}
}

View File

@ -1,40 +1,41 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { buildDefaultScene } from "@/lib/scenes";
export class CreateSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private createdScene: TScene | null = null;
constructor(
private name: string,
private isMain: boolean = false,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedScenes = [...editor.scenes.getScenes()];
this.createdScene = buildDefaultScene({
name: this.name,
isMain: this.isMain,
});
const updatedScenes = [...this.savedScenes, this.createdScene];
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
getSceneId(): string {
return this.createdScene?.id ?? "";
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { buildDefaultScene } from "@/lib/scenes";
export class CreateSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private createdScene: TScene | null = null;
constructor(
private name: string,
private isMain: boolean = false,
) {
super();
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedScenes = [...editor.scenes.getScenes()];
this.createdScene = buildDefaultScene({
name: this.name,
isMain: this.isMain,
});
const updatedScenes = [...this.savedScenes, this.createdScene];
editor.scenes.setScenes({ scenes: updatedScenes });
return undefined;
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
getSceneId(): string {
return this.createdScene?.id ?? "";
}
}

View File

@ -1,59 +1,59 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
export class DeleteSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private savedActiveSceneId: string | null = null;
private deletedScene: TScene | null = null;
constructor(private sceneId: string) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes();
const activeScene = editor.scenes.getActiveScene();
this.savedScenes = [...scenes];
this.savedActiveSceneId = activeScene?.id ?? null;
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
if (!this.deletedScene) {
console.error("Scene not found:", this.sceneId);
return;
}
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
if (!canDelete) {
console.error("Cannot delete scene:", reason);
return;
}
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
const newActiveScene = getFallbackSceneAfterDelete({
scenes: updatedScenes,
deletedSceneId: this.sceneId,
currentSceneId: activeScene?.id ?? null,
});
editor.scenes.setScenes({
scenes: updatedScenes,
activeSceneId: newActiveScene?.id,
});
}
undo(): void {
if (this.savedScenes && this.deletedScene) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({
scenes: this.savedScenes,
activeSceneId: this.savedActiveSceneId ?? undefined,
});
}
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
export class DeleteSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private savedActiveSceneId: string | null = null;
private deletedScene: TScene | null = null;
constructor(private sceneId: string) {
super();
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes();
const activeScene = editor.scenes.getActiveScene();
this.savedScenes = [...scenes];
this.savedActiveSceneId = activeScene?.id ?? null;
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
if (!this.deletedScene) {
console.error("Scene not found:", this.sceneId);
return;
}
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
if (!canDelete) {
console.error("Cannot delete scene:", reason);
return;
}
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
const newActiveScene = getFallbackSceneAfterDelete({
scenes: updatedScenes,
deletedSceneId: this.sceneId,
currentSceneId: activeScene?.id ?? null,
});
editor.scenes.setScenes({
scenes: updatedScenes,
activeSceneId: newActiveScene?.id,
});
}
undo(): void {
if (this.savedScenes && this.deletedScene) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({
scenes: this.savedScenes,
activeSceneId: this.savedActiveSceneId ?? undefined,
});
}
}
}

View File

@ -1,7 +1,7 @@
export { CreateSceneCommand } from "./create-scene";
export { DeleteSceneCommand } from "./delete-scene";
export { RenameSceneCommand } from "./rename-scene";
export { ToggleBookmarkCommand } from "./toggle-bookmark";
export { RemoveBookmarkCommand } from "./remove-bookmark";
export { UpdateBookmarkCommand } from "./update-bookmark";
export { MoveBookmarkCommand } from "./move-bookmark";
export { CreateSceneCommand } from "./create-scene";
export { DeleteSceneCommand } from "./delete-scene";
export { RenameSceneCommand } from "./rename-scene";
export { ToggleBookmarkCommand } from "./toggle-bookmark";
export { RemoveBookmarkCommand } from "./remove-bookmark";
export { UpdateBookmarkCommand } from "./update-bookmark";
export { MoveBookmarkCommand } from "./move-bookmark";

View File

@ -1,59 +1,59 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
constructor(
private fromTime: number,
private toTime: number,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) {
return;
}
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
const fromFrameTime = getFrameTime({
time: this.fromTime,
fps: activeProject.settings.fps,
});
const toFrameTime = getFrameTime({
time: this.toTime,
fps: activeProject.settings.fps,
});
const updatedBookmarks = moveBookmarkInArray({
bookmarks: activeScene.bookmarks,
fromTime: fromFrameTime,
toTime: toFrameTime,
});
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
constructor(
private fromTime: number,
private toTime: number,
) {
super();
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) {
return;
}
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
const fromFrameTime = getFrameTime({
time: this.fromTime,
fps: activeProject.settings.fps,
});
const toFrameTime = getFrameTime({
time: this.toTime,
fps: activeProject.settings.fps,
});
const updatedBookmarks = moveBookmarkInArray({
bookmarks: activeScene.bookmarks,
fromTime: fromFrameTime,
toTime: toFrameTime,
});
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}

View File

@ -1,59 +1,59 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import {
getFrameTime,
removeBookmarkFromArray,
} from "@/lib/timeline/bookmarks";
export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
private frameTime: number = 0;
constructor(private time: number) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) {
return;
}
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
this.frameTime = getFrameTime({
time: this.time,
fps: activeProject.settings.fps,
});
const updatedBookmarks = removeBookmarkFromArray({
bookmarks: activeScene.bookmarks,
frameTime: this.frameTime,
});
if (updatedBookmarks.length === activeScene.bookmarks.length) {
return;
}
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import {
getFrameTime,
removeBookmarkFromArray,
} from "@/lib/timeline/bookmarks";
export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
private frameTime: number = 0;
constructor(private time: number) {
super();
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) {
return;
}
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
this.frameTime = getFrameTime({
time: this.time,
fps: activeProject.settings.fps,
});
const updatedBookmarks = removeBookmarkFromArray({
bookmarks: activeScene.bookmarks,
frameTime: this.frameTime,
});
if (updatedBookmarks.length === activeScene.bookmarks.length) {
return;
}
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}

View File

@ -1,46 +1,46 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
export class RenameSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private previousName: string | null = null;
constructor(
private sceneId: string,
private newName: string,
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
const scene = scenes.find((s) => s.id === this.sceneId);
if (!scene) {
console.error("Scene not found:", this.sceneId);
return;
}
this.previousName = scene.name;
const updatedScenes = updateSceneInArray({
scenes,
sceneId: this.sceneId,
updates: { name: this.newName, updatedAt: new Date() },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes && this.previousName !== null) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
export class RenameSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private previousName: string | null = null;
constructor(
private sceneId: string,
private newName: string,
) {
super();
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
const scene = scenes.find((s) => s.id === this.sceneId);
if (!scene) {
console.error("Scene not found:", this.sceneId);
return;
}
this.previousName = scene.name;
const updatedScenes = updateSceneInArray({
scenes,
sceneId: this.sceneId,
updates: { name: this.newName, updatedAt: new Date() },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes && this.previousName !== null) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}

View File

@ -1,52 +1,52 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
private frameTime: number = 0;
constructor(private time: number) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) {
return;
}
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
this.frameTime = getFrameTime({
time: this.time,
fps: activeProject.settings.fps,
});
const updatedBookmarks = toggleBookmarkInArray({
bookmarks: activeScene.bookmarks,
frameTime: this.frameTime,
});
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
private frameTime: number = 0;
constructor(private time: number) {
super();
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) {
return;
}
const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes];
this.frameTime = getFrameTime({
time: this.time,
fps: activeProject.settings.fps,
});
const updatedBookmarks = toggleBookmarkInArray({
bookmarks: activeScene.bookmarks,
frameTime: this.frameTime,
});
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
editor.scenes.setScenes({ scenes: updatedScenes });
}
undo(): void {
if (this.savedScenes) {
const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes });
}
}
}

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