Merge branch 'main' into feature/subtitles
# Conflicts: # apps/web/src/components/editor/panels/assets/views/captions.tsx
This commit is contained in:
commit
423f4acde6
|
|
@ -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
|
||||
|
|
|
|||
16
AGENTS.md
16
AGENTS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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] },
|
||||
];
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
export const TIME_EPSILON_SECONDS = 1 / 1000;
|
||||
export const MIN_TRANSFORM_SCALE = 0.01;
|
||||
export const MIN_TRANSFORM_SCALE = 0.01;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>) =>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? "";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue