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"
|
MARBLE_WORKSPACE_KEY: "placeholder"
|
||||||
FREESOUND_CLIENT_ID: "placeholder"
|
FREESOUND_CLIENT_ID: "placeholder"
|
||||||
FREESOUND_API_KEY: "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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
|
||||||
16
AGENTS.md
16
AGENTS.md
|
|
@ -1,13 +1,19 @@
|
||||||
# Agents.md
|
# Agents.md
|
||||||
|
|
||||||
## Apps
|
## Architecture
|
||||||
|
|
||||||
- Web
|
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.
|
||||||
- Desktop
|
|
||||||
|
|
||||||
## 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
|
## Web
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1427,6 +1427,16 @@ version = "1.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "effects"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"gpu",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"wgpu",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
|
@ -3217,6 +3227,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "masks"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"gpu",
|
||||||
|
"wgpu",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -3777,10 +3796,14 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opencut-wasm"
|
name = "opencut-wasm"
|
||||||
version = "0.1.2"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bridge",
|
||||||
|
"effects",
|
||||||
"gpu",
|
"gpu",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"masks",
|
||||||
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
"time",
|
"time",
|
||||||
|
|
@ -5581,6 +5604,7 @@ name = "time"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bridge",
|
"bridge",
|
||||||
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"tsify-next",
|
"tsify-next",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,8 @@ members = [
|
||||||
"apps/desktop",
|
"apps/desktop",
|
||||||
"rust/crates/time",
|
"rust/crates/time",
|
||||||
"rust/crates/bridge",
|
"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_CLIENT_ID=your_client_id_here
|
||||||
FREESOUND_API_KEY=your_api_key_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_SITE_URL="http://localhost:3000"
|
||||||
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
|
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
|
||||||
ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY
|
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_CLIENT_ID=$FREESOUND_CLIENT_ID
|
||||||
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
|
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."
|
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
|
||||||
- type: new
|
- type: new
|
||||||
text: "You can now import transcript files to generate captions instead of running auto-transcription."
|
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 * as dotenv from "dotenv";
|
||||||
import { webEnv } from "@/lib/env/web";
|
import { webEnv } from "@/lib/env/web";
|
||||||
|
|
||||||
// Load the right env file based on environment
|
|
||||||
if (webEnv.NODE_ENV === "production") {
|
if (webEnv.NODE_ENV === "production") {
|
||||||
dotenv.config({ path: ".env.production" });
|
dotenv.config({ path: ".env.production" });
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"opencut-wasm": "^0.1.2",
|
"opencut-wasm": "^0.2.3",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import type {
|
||||||
TProjectSortKey,
|
TProjectSortKey,
|
||||||
TProjectSortOption,
|
TProjectSortOption,
|
||||||
} from "@/lib/project/types";
|
} from "@/lib/project/types";
|
||||||
import { formatTimeCode } from "opencut-wasm";
|
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||||
import { formatDate } from "@/utils/date";
|
import { formatDate } from "@/utils/date";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -76,8 +76,9 @@ const formatProjectDuration = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
|
const durationSeconds = mediaTimeToSeconds({ time: duration });
|
||||||
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
|
const format = durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS";
|
||||||
|
return formatTimecode({ time: duration, format }) ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const VIEW_MODE_OPTIONS = [
|
const VIEW_MODE_OPTIONS = [
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
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";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
interface EditableTimecodeProps {
|
interface EditableTimecodeProps {
|
||||||
time: number;
|
time: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
format?: TimeCodeFormat;
|
format?: TimeCodeFormat;
|
||||||
fps: number;
|
fps: FrameRate;
|
||||||
onTimeChange?: ({ time }: { time: number }) => void;
|
onTimeChange?: ({ time }: { time: number }) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -28,7 +28,7 @@ export function EditableTimecode({
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const enterPressedRef = useRef(false);
|
const enterPressedRef = useRef(false);
|
||||||
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
|
const formattedTime = formatTimecode({ time, format, rate: fps }) ?? "";
|
||||||
|
|
||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
@ -46,17 +46,16 @@ export function EditableTimecode({
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyEdit = () => {
|
const applyEdit = () => {
|
||||||
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
|
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
|
||||||
|
|
||||||
if (parsedTime == null) {
|
if (parsedTime == null) {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampedTime = Math.max(
|
const clampedTime = duration
|
||||||
0,
|
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
|
||||||
duration ? Math.min(duration, parsedTime) : parsedTime,
|
: parsedTime;
|
||||||
);
|
|
||||||
|
|
||||||
onTimeChange?.({ time: clampedTime });
|
onTimeChange?.({ time: clampedTime });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { TProjectMetadata } from "@/lib/project/types";
|
import type { TProjectMetadata } from "@/lib/project/types";
|
||||||
import { formatDate } from "@/utils/date";
|
import { formatDate } from "@/utils/date";
|
||||||
import { formatTimeCode } from "opencut-wasm";
|
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
function InfoRow({
|
function InfoRow({
|
||||||
|
|
@ -35,9 +35,10 @@ export function ProjectInfoDialog({
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
project: TProjectMetadata;
|
project: TProjectMetadata;
|
||||||
}) {
|
}) {
|
||||||
|
const durationSeconds = mediaTimeToSeconds({ time: project.duration });
|
||||||
const durationFormatted =
|
const durationFormatted =
|
||||||
project.duration > 0
|
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";
|
: "0:00";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} 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 { useEditor } from "@/hooks/use-editor";
|
||||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||||
import { invokeAction } from "@/lib/actions";
|
import { invokeAction } from "@/lib/actions";
|
||||||
|
|
@ -257,7 +257,7 @@ function MediaAssetDraggable({
|
||||||
startTime: number;
|
startTime: number;
|
||||||
}) => {
|
}) => {
|
||||||
const duration =
|
const duration =
|
||||||
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const element = buildElementFromMedia({
|
const element = buildElementFromMedia({
|
||||||
mediaId: asset.id,
|
mediaId: asset.id,
|
||||||
mediaType: asset.type,
|
mediaType: asset.type,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export function Captions() {
|
||||||
setProcessingStep("Extracting audio...");
|
setProcessingStep("Extracting audio...");
|
||||||
|
|
||||||
const audioBlob = await extractTimelineAudio({
|
const audioBlob = await extractTimelineAudio({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks: editor.scenes.getActiveScene().tracks,
|
||||||
mediaAssets: editor.media.getAssets(),
|
mediaAssets: editor.media.getAssets(),
|
||||||
totalDuration: editor.timeline.getTotalDuration(),
|
totalDuration: editor.timeline.getTotalDuration(),
|
||||||
});
|
});
|
||||||
|
|
@ -274,7 +274,7 @@ export function Captions() {
|
||||||
)}
|
)}
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3">
|
<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) => (
|
{warnings.map((warning) => (
|
||||||
<li key={warning}>{warning}</li>
|
<li key={warning}>{warning}</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { FPS_PRESETS } from "@/lib/fps/constants";
|
import { FPS_PRESETS } from "@/lib/fps/constants";
|
||||||
|
import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
|
|
@ -232,12 +233,12 @@ export function SettingsView() {
|
||||||
<Section showTopBorder={false}>
|
<Section showTopBorder={false}>
|
||||||
<SectionHeader className="justify-between">
|
<SectionHeader className="justify-between">
|
||||||
<SectionTitle className="flex-1">Frame rate</SectionTitle>
|
<SectionTitle className="flex-1">Frame rate</SectionTitle>
|
||||||
<Select
|
<Select
|
||||||
value={activeProject.settings.fps.toString()}
|
value={String(Math.round(frameRateToFloat(activeProject.settings.fps)))}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const fps = parseFloat(value);
|
const fps = floatToFrameRate(parseFloat(value));
|
||||||
editor.project.updateSettings({ settings: { fps } });
|
editor.project.updateSettings({ settings: { fps } });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
|
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
|
||||||
<SelectValue placeholder="Select a frame rate" />
|
<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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
|
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
|
||||||
import {
|
import {
|
||||||
|
|
@ -70,41 +71,25 @@ export function StickersView() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex min-h-0 flex-1 flex-col">
|
<Tabs
|
||||||
<div className="border-b border-border px-2">
|
value={selectedCategory}
|
||||||
<div
|
onValueChange={(value) => {
|
||||||
role="tablist"
|
setSelectedCategory({ category: value as StickerCategory });
|
||||||
aria-label="Sticker categories"
|
}}
|
||||||
className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
|
variant="underline"
|
||||||
>
|
className="mt-2 flex min-h-0 flex-1 flex-col"
|
||||||
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
|
>
|
||||||
const isActive = key === selectedCategory;
|
<TabsList aria-label="Sticker categories">
|
||||||
return (
|
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
|
||||||
<button
|
<TabsTrigger key={key} value={key}>
|
||||||
key={key}
|
{label}
|
||||||
type="button"
|
</TabsTrigger>
|
||||||
role="tab"
|
))}
|
||||||
aria-selected={isActive}
|
</TabsList>
|
||||||
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>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
|
||||||
<StickersContentView />
|
<StickersContentView />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||||
import { useContainerSize } from "@/hooks/use-container-size";
|
import { useContainerSize } from "@/hooks/use-container-size";
|
||||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||||
import { buildScene } from "@/services/renderer/scene-builder";
|
import { buildScene } from "@/services/renderer/scene-builder";
|
||||||
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
||||||
|
|
@ -72,7 +73,9 @@ export function PreviewPanel() {
|
||||||
|
|
||||||
function RenderTreeController() {
|
function RenderTreeController() {
|
||||||
const editor = useEditor();
|
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 mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
const activeProject = useEditor((e) => e.project.getActive());
|
const activeProject = useEditor((e) => e.project.getActive());
|
||||||
|
|
||||||
|
|
@ -129,12 +132,16 @@ function PreviewCanvas({
|
||||||
height: nativeHeight,
|
height: nativeHeight,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
|
}, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]);
|
||||||
|
|
||||||
const render = useCallback(() => {
|
const render = useCallback(() => {
|
||||||
if (canvasRef.current && renderTree && !renderingRef.current) {
|
if (canvasRef.current && renderTree && !renderingRef.current) {
|
||||||
const renderTime = editor.playback.getCurrentTime();
|
const renderTime = Math.min(
|
||||||
const frame = Math.floor(renderTime * renderer.fps);
|
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 (
|
if (
|
||||||
frame !== lastFrameRef.current ||
|
frame !== lastFrameRef.current ||
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { formatTimeCode } from "opencut-wasm";
|
import { formatTimecode } from "opencut-wasm";
|
||||||
import { invokeAction } from "@/lib/actions";
|
import { invokeAction } from "@/lib/actions";
|
||||||
import { EditableTimecode } from "@/components/editable-timecode";
|
import { EditableTimecode } from "@/components/editable-timecode";
|
||||||
import { Button } from "@/components/ui/button";
|
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 px-2 font-mono text-xs">/</span>
|
||||||
<span className="text-muted-foreground font-mono text-xs">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,22 @@
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { getElementLocalTime } from "@/lib/animation";
|
import { getElementLocalTime } from "@/lib/animation";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
|
export function useElementPlayhead({
|
||||||
export function useElementPlayhead({
|
startTime,
|
||||||
startTime,
|
duration,
|
||||||
duration,
|
}: {
|
||||||
}: {
|
startTime: number;
|
||||||
startTime: number;
|
duration: number;
|
||||||
duration: number;
|
}) {
|
||||||
}) {
|
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||||
const editor = useEditor();
|
const localTime = getElementLocalTime({
|
||||||
const playheadTime = editor.playback.getCurrentTime();
|
timelineTime: playheadTime,
|
||||||
const localTime = getElementLocalTime({
|
elementStartTime: startTime,
|
||||||
timelineTime: playheadTime,
|
elementDuration: duration,
|
||||||
elementStartTime: startTime,
|
});
|
||||||
elementDuration: duration,
|
const isPlayheadWithinElementRange =
|
||||||
});
|
playheadTime >= startTime &&
|
||||||
const isPlayheadWithinElementRange =
|
playheadTime <= startTime + duration;
|
||||||
playheadTime >= startTime - TIME_EPSILON_SECONDS &&
|
|
||||||
playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
|
return { localTime, isPlayheadWithinElementRange };
|
||||||
|
}
|
||||||
return { localTime, isPlayheadWithinElementRange };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export function useKeyframedNumberProperty({
|
||||||
valueAtPlayhead,
|
valueAtPlayhead,
|
||||||
step,
|
step,
|
||||||
buildBaseUpdates,
|
buildBaseUpdates,
|
||||||
|
buildAdditionalKeyframes,
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
|
|
@ -33,6 +34,9 @@ export function useKeyframedNumberProperty({
|
||||||
valueAtPlayhead: number;
|
valueAtPlayhead: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
||||||
|
buildAdditionalKeyframes?: ({
|
||||||
|
value,
|
||||||
|
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const snapValue = (value: number) =>
|
const snapValue = (value: number) =>
|
||||||
|
|
@ -50,19 +54,26 @@ export function useKeyframedNumberProperty({
|
||||||
const previewValue = ({ value }: { value: number }) => {
|
const previewValue = ({ value }: { value: number }) => {
|
||||||
const nextValue = snapValue(value);
|
const nextValue = snapValue(value);
|
||||||
if (shouldUseAnimatedChannel) {
|
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({
|
editor.timeline.previewElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: {
|
updates: { animations: updatedAnimations },
|
||||||
animations: upsertElementKeyframe({
|
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
time: localTime,
|
|
||||||
value: nextValue,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -125,15 +136,17 @@ export function useKeyframedNumberProperty({
|
||||||
const commitValue = ({ value }: { value: number }) => {
|
const commitValue = ({ value }: { value: number }) => {
|
||||||
const nextValue = snapValue(value);
|
const nextValue = snapValue(value);
|
||||||
if (shouldUseAnimatedChannel) {
|
if (shouldUseAnimatedChannel) {
|
||||||
|
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||||
editor.timeline.upsertKeyframes({
|
editor.timeline.upsertKeyframes({
|
||||||
keyframes: [
|
keyframes: [
|
||||||
{
|
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
|
||||||
|
...additionalKeyframes.map((keyframe) => ({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
propertyPath,
|
propertyPath: keyframe.propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value: nextValue,
|
value: keyframe.value,
|
||||||
},
|
})),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -144,7 +157,7 @@ export function useKeyframedNumberProperty({
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: buildBaseUpdates({ value: nextValue }),
|
patch: buildBaseUpdates({ value: nextValue }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,148 +1,150 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import {
|
import {
|
||||||
buildGraphicParamPath,
|
buildGraphicParamPath,
|
||||||
getKeyframeAtTime,
|
coerceAnimationValueForParam,
|
||||||
getParamDefaultInterpolation,
|
getKeyframeAtTime,
|
||||||
getParamValueKind,
|
getParamDefaultInterpolation,
|
||||||
hasKeyframesForPath,
|
getParamValueKind,
|
||||||
upsertPathKeyframe,
|
hasKeyframesForPath,
|
||||||
} from "@/lib/animation";
|
upsertPathKeyframe,
|
||||||
import type {
|
} from "@/lib/animation";
|
||||||
ElementAnimations,
|
import type {
|
||||||
} from "@/lib/animation/types";
|
ElementAnimations,
|
||||||
import type { ParamDefinition } from "@/lib/params";
|
} from "@/lib/animation/types";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import type { ParamDefinition } from "@/lib/params";
|
||||||
|
import type { TimelineElement } from "@/lib/timeline";
|
||||||
export interface KeyframedParamPropertyResult {
|
|
||||||
hasAnimatedKeyframes: boolean;
|
export interface KeyframedParamPropertyResult {
|
||||||
isKeyframedAtTime: boolean;
|
hasAnimatedKeyframes: boolean;
|
||||||
keyframeIdAtTime: string | null;
|
isKeyframedAtTime: boolean;
|
||||||
onPreview: (value: number | string | boolean) => void;
|
keyframeIdAtTime: string | null;
|
||||||
onCommit: () => void;
|
onPreview: (value: number | string | boolean) => void;
|
||||||
toggleKeyframe: () => void;
|
onCommit: () => void;
|
||||||
}
|
toggleKeyframe: () => void;
|
||||||
|
}
|
||||||
export function useKeyframedParamProperty({
|
|
||||||
param,
|
export function useKeyframedParamProperty({
|
||||||
trackId,
|
param,
|
||||||
elementId,
|
trackId,
|
||||||
animations,
|
elementId,
|
||||||
localTime,
|
animations,
|
||||||
isPlayheadWithinElementRange,
|
localTime,
|
||||||
resolvedValue,
|
isPlayheadWithinElementRange,
|
||||||
buildBaseUpdates,
|
resolvedValue,
|
||||||
}: {
|
buildBaseUpdates,
|
||||||
param: ParamDefinition;
|
}: {
|
||||||
trackId: string;
|
param: ParamDefinition;
|
||||||
elementId: string;
|
trackId: string;
|
||||||
animations: ElementAnimations | undefined;
|
elementId: string;
|
||||||
localTime: number;
|
animations: ElementAnimations | undefined;
|
||||||
isPlayheadWithinElementRange: boolean;
|
localTime: number;
|
||||||
resolvedValue: number | string | boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
buildBaseUpdates: ({
|
resolvedValue: number | string | boolean;
|
||||||
value,
|
buildBaseUpdates: ({
|
||||||
}: {
|
value,
|
||||||
value: number | string | boolean;
|
}: {
|
||||||
}) => Partial<TimelineElement>;
|
value: number | string | boolean;
|
||||||
}): KeyframedParamPropertyResult {
|
}) => Partial<TimelineElement>;
|
||||||
const editor = useEditor();
|
}): KeyframedParamPropertyResult {
|
||||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
const editor = useEditor();
|
||||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||||
animations,
|
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||||
propertyPath,
|
animations,
|
||||||
});
|
propertyPath,
|
||||||
const keyframeAtTime = isPlayheadWithinElementRange
|
});
|
||||||
? getKeyframeAtTime({
|
const keyframeAtTime = isPlayheadWithinElementRange
|
||||||
animations,
|
? getKeyframeAtTime({
|
||||||
propertyPath,
|
animations,
|
||||||
time: localTime,
|
propertyPath,
|
||||||
})
|
time: localTime,
|
||||||
: null;
|
})
|
||||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
: null;
|
||||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||||
const shouldUseAnimatedChannel =
|
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
const shouldUseAnimatedChannel =
|
||||||
|
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||||
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
|
||||||
if (shouldUseAnimatedChannel) {
|
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
||||||
editor.timeline.previewElements({
|
if (shouldUseAnimatedChannel) {
|
||||||
updates: [
|
editor.timeline.previewElements({
|
||||||
{
|
updates: [
|
||||||
trackId,
|
{
|
||||||
elementId,
|
trackId,
|
||||||
updates: {
|
elementId,
|
||||||
animations: upsertPathKeyframe({
|
updates: {
|
||||||
animations,
|
animations: upsertPathKeyframe({
|
||||||
propertyPath,
|
animations,
|
||||||
time: localTime,
|
propertyPath,
|
||||||
value,
|
time: localTime,
|
||||||
valueKind: getParamValueKind({ param }),
|
value,
|
||||||
defaultInterpolation: getParamDefaultInterpolation({
|
kind: getParamValueKind({ param }),
|
||||||
param,
|
defaultInterpolation: getParamDefaultInterpolation({
|
||||||
}),
|
param,
|
||||||
numericRange:
|
}),
|
||||||
param.type === "number"
|
coerceValue: ({ value: nextValue }) =>
|
||||||
? { min: param.min, max: param.max, step: param.step }
|
coerceAnimationValueForParam({
|
||||||
: undefined,
|
param,
|
||||||
}),
|
value: nextValue,
|
||||||
},
|
}),
|
||||||
},
|
}),
|
||||||
],
|
},
|
||||||
});
|
},
|
||||||
return;
|
],
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
editor.timeline.previewElements({
|
}
|
||||||
updates: [
|
|
||||||
{
|
editor.timeline.previewElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId,
|
{
|
||||||
updates: buildBaseUpdates({ value }),
|
trackId,
|
||||||
},
|
elementId,
|
||||||
],
|
updates: buildBaseUpdates({ value }),
|
||||||
});
|
},
|
||||||
};
|
],
|
||||||
|
});
|
||||||
const toggleKeyframe = () => {
|
};
|
||||||
if (!isPlayheadWithinElementRange) {
|
|
||||||
return;
|
const toggleKeyframe = () => {
|
||||||
}
|
if (!isPlayheadWithinElementRange) {
|
||||||
|
return;
|
||||||
if (keyframeIdAtTime) {
|
}
|
||||||
editor.timeline.removeKeyframes({
|
|
||||||
keyframes: [
|
if (keyframeIdAtTime) {
|
||||||
{
|
editor.timeline.removeKeyframes({
|
||||||
trackId,
|
keyframes: [
|
||||||
elementId,
|
{
|
||||||
propertyPath,
|
trackId,
|
||||||
keyframeId: keyframeIdAtTime,
|
elementId,
|
||||||
},
|
propertyPath,
|
||||||
],
|
keyframeId: keyframeIdAtTime,
|
||||||
});
|
},
|
||||||
return;
|
],
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
editor.timeline.upsertKeyframes({
|
}
|
||||||
keyframes: [
|
|
||||||
{
|
editor.timeline.upsertKeyframes({
|
||||||
trackId,
|
keyframes: [
|
||||||
elementId,
|
{
|
||||||
propertyPath,
|
trackId,
|
||||||
time: localTime,
|
elementId,
|
||||||
value: resolvedValue,
|
propertyPath,
|
||||||
},
|
time: localTime,
|
||||||
],
|
value: resolvedValue,
|
||||||
});
|
},
|
||||||
};
|
],
|
||||||
|
});
|
||||||
return {
|
};
|
||||||
hasAnimatedKeyframes,
|
|
||||||
isKeyframedAtTime,
|
return {
|
||||||
keyframeIdAtTime,
|
hasAnimatedKeyframes,
|
||||||
onPreview: previewValue,
|
isKeyframedAtTime,
|
||||||
onCommit: () => editor.timeline.commitPreview(),
|
keyframeIdAtTime,
|
||||||
toggleKeyframe,
|
onPreview: previewValue,
|
||||||
};
|
onCommit: () => editor.timeline.commitPreview(),
|
||||||
}
|
toggleKeyframe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ export function useKeyframedVectorProperty({
|
||||||
}
|
}
|
||||||
editor.timeline.updateElements({
|
editor.timeline.updateElements({
|
||||||
updates: [
|
updates: [
|
||||||
{ trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
|
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -172,7 +172,7 @@ export function useKeyframedVectorProperty({
|
||||||
}
|
}
|
||||||
editor.timeline.updateElements({
|
editor.timeline.updateElements({
|
||||||
updates: [
|
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() {
|
export function PropertiesPanel() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
useEditor((e) => e.timeline.getTracks());
|
useEditor((e) => e.scenes.getActiveSceneOrNull());
|
||||||
useEditor((e) => e.media.getAssets());
|
useEditor((e) => e.media.getAssets());
|
||||||
const { selectedElements } = useElementSelection();
|
const { selectedElements } = useElementSelection();
|
||||||
const { activeTabPerType, setActiveTab } = usePropertiesStore();
|
const { activeTabPerType, setActiveTab } = usePropertiesStore();
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ type BlendingElement = {
|
||||||
animations?: ElementAnimations;
|
animations?: ElementAnimations;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BLEND_MODE_GROUPS = [
|
const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
|
||||||
[{ value: "normal", label: "Normal" }],
|
[{ value: "normal", label: "Normal" }],
|
||||||
[
|
[
|
||||||
{ value: "darken", label: "Darken" },
|
{ value: "darken", label: "Darken" },
|
||||||
|
|
@ -99,7 +99,7 @@ export function BlendingTab({
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const commitBlendMode = (value: string) => {
|
const commitBlendMode = (value: BlendMode) => {
|
||||||
if (editor.timeline.isPreviewActive()) {
|
if (editor.timeline.isPreviewActive()) {
|
||||||
editor.timeline.commitPreview();
|
editor.timeline.commitPreview();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -108,7 +108,7 @@ export function BlendingTab({
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { blendMode: value as BlendMode },
|
patch: { blendMode: value },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -209,7 +209,7 @@ export function BlendingTab({
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
onPointerEnter={() =>
|
onPointerEnter={() =>
|
||||||
previewBlendMode({ value: option.value as BlendMode })
|
previewBlendMode({ value: option.value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ function StrokeSection({
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { params: { ...element.params, strokeWidth: 0 } },
|
patch: { params: { ...element.params, strokeWidth: 0 } },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -133,7 +133,7 @@ function StrokeSection({
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: {
|
patch: {
|
||||||
params: {
|
params: {
|
||||||
...element.params,
|
...element.params,
|
||||||
strokeWidth: lastStrokeWidth.current,
|
strokeWidth: lastStrokeWidth.current,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
PlusSignIcon,
|
PlusSignIcon,
|
||||||
RotateClockwiseIcon,
|
RotateClockwiseIcon,
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ColorPicker } from "@/components/ui/color-picker";
|
import { ColorPicker } from "@/components/ui/color-picker";
|
||||||
|
|
@ -88,7 +87,9 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
|
||||||
fallback: element,
|
fallback: element,
|
||||||
});
|
});
|
||||||
const maskDefs = masksRegistry.getAll();
|
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 currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
const canvasSize = useEditor(
|
const canvasSize = useEditor(
|
||||||
|
|
@ -102,7 +103,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
|
||||||
const elementBounds = useMemo(() => {
|
const elementBounds = useMemo(() => {
|
||||||
const clampedTime = Math.min(
|
const clampedTime = Math.min(
|
||||||
Math.max(currentTime, element.startTime),
|
Math.max(currentTime, element.startTime),
|
||||||
element.startTime + element.duration - TIME_EPSILON_SECONDS,
|
element.startTime + element.duration - 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -165,7 +166,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: {
|
patch: {
|
||||||
masks: [
|
masks: [
|
||||||
buildDefaultMaskInstance({
|
buildDefaultMaskInstance({
|
||||||
maskType,
|
maskType,
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ function TypographySection({
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { fontFamily: value },
|
patch: { fontFamily: value },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
@ -188,7 +188,7 @@ function TypographySection({
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: {
|
patch: {
|
||||||
fontSize: DEFAULTS.text.element.fontSize,
|
fontSize: DEFAULTS.text.element.fontSize,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -291,7 +291,7 @@ function SpacingSection({
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { letterSpacing: DEFAULTS.text.letterSpacing },
|
patch: { letterSpacing: DEFAULTS.text.letterSpacing },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
@ -317,7 +317,7 @@ function SpacingSection({
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { lineHeight: DEFAULTS.text.lineHeight },
|
patch: { lineHeight: DEFAULTS.text.lineHeight },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
@ -512,7 +512,7 @@ function BackgroundSection({
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: {
|
patch: {
|
||||||
background: {
|
background: {
|
||||||
...element.background,
|
...element.background,
|
||||||
enabled,
|
enabled,
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,9 @@ export function TransformTab({
|
||||||
...(isScaleLocked ? { scaleY: value } : {}),
|
...(isScaleLocked ? { scaleY: value } : {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
buildAdditionalKeyframes: isScaleLocked
|
||||||
|
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const scaleY = useKeyframedNumberProperty({
|
const scaleY = useKeyframedNumberProperty({
|
||||||
|
|
@ -140,6 +143,9 @@ export function TransformTab({
|
||||||
...(isScaleLocked ? { scaleX: value } : {}),
|
...(isScaleLocked ? { scaleX: value } : {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
buildAdditionalKeyframes: isScaleLocked
|
||||||
|
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const scaleFieldPropsX = {
|
const scaleFieldPropsX = {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,12 @@ import { useEffect, useState } from "react";
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
||||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_TIMELINE_BOOKMARK_COLOR,
|
DEFAULT_TIMELINE_BOOKMARK_COLOR,
|
||||||
} from "./theme";
|
} from "./theme";
|
||||||
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
|
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
|
||||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||||
import { getSnappedSeekTime } from "opencut-wasm";
|
import { snappedSeekTime } from "opencut-wasm";
|
||||||
import {
|
import {
|
||||||
ArrowTurnBackwardIcon,
|
ArrowTurnBackwardIcon,
|
||||||
Delete02Icon,
|
Delete02Icon,
|
||||||
|
|
@ -49,8 +48,8 @@ function seekToBookmarkTime({
|
||||||
}) {
|
}) {
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
const duration = editor.timeline.getTotalDuration();
|
const duration = editor.timeline.getTotalDuration();
|
||||||
const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
|
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
|
||||||
const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
|
const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
|
||||||
editor.playback.seek({ time: snappedTime });
|
editor.playback.seek({ time: snappedTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,7 +138,7 @@ function TimelineBookmark({
|
||||||
const isDragging =
|
const isDragging =
|
||||||
dragState.isDragging &&
|
dragState.isDragging &&
|
||||||
dragState.bookmarkTime !== null &&
|
dragState.bookmarkTime !== null &&
|
||||||
Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
|
dragState.bookmarkTime === bookmark.time;
|
||||||
|
|
||||||
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
|
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
|
||||||
const time = bookmark.time;
|
const time = bookmark.time;
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ export function computeDropTarget({
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
targetElementTypes,
|
targetElementTypes,
|
||||||
}: ComputeDropTargetParams): DropTarget {
|
}: ComputeDropTargetParams): DropTarget {
|
||||||
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
|
const mainTrackIndex = tracks.overlay.length;
|
||||||
const xPosition =
|
const xPosition =
|
||||||
typeof startTimeOverride === "number"
|
typeof startTimeOverride === "number"
|
||||||
? startTimeOverride
|
? startTimeOverride
|
||||||
|
|
@ -112,7 +114,7 @@ export function computeDropTarget({
|
||||||
? playheadTime
|
? playheadTime
|
||||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||||
|
|
||||||
if (tracks.length === 0) {
|
if (orderedTracks.length === 0) {
|
||||||
const placementResult = resolveTrackPlacement({
|
const placementResult = resolveTrackPlacement({
|
||||||
tracks,
|
tracks,
|
||||||
elementType,
|
elementType,
|
||||||
|
|
@ -139,7 +141,11 @@ export function computeDropTarget({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
|
const trackAtMouse = getTrackAtY({
|
||||||
|
mouseY,
|
||||||
|
tracks: orderedTracks,
|
||||||
|
verticalDragDirection,
|
||||||
|
});
|
||||||
|
|
||||||
if (!trackAtMouse) {
|
if (!trackAtMouse) {
|
||||||
const isAboveAllTracks = mouseY < 0;
|
const isAboveAllTracks = mouseY < 0;
|
||||||
|
|
@ -150,7 +156,7 @@ export function computeDropTarget({
|
||||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||||
strategy: {
|
strategy: {
|
||||||
type: "preferIndex",
|
type: "preferIndex",
|
||||||
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1,
|
||||||
hoverDirection: isAboveAllTracks ? "above" : "below",
|
hoverDirection: isAboveAllTracks ? "above" : "below",
|
||||||
createNewTrackOnly: true,
|
createNewTrackOnly: true,
|
||||||
},
|
},
|
||||||
|
|
@ -171,12 +177,12 @@ export function computeDropTarget({
|
||||||
}
|
}
|
||||||
|
|
||||||
const { trackIndex, relativeY } = trackAtMouse;
|
const { trackIndex, relativeY } = trackAtMouse;
|
||||||
const track = tracks[trackIndex];
|
const track = orderedTracks[trackIndex];
|
||||||
|
|
||||||
if (targetElementTypes && targetElementTypes.length > 0) {
|
if (targetElementTypes && targetElementTypes.length > 0) {
|
||||||
const targetElement = findElementAtPosition({
|
const targetElement = findElementAtPosition({
|
||||||
mouseX,
|
mouseX,
|
||||||
tracks,
|
tracks: orderedTracks,
|
||||||
trackIndex,
|
trackIndex,
|
||||||
targetElementTypes,
|
targetElementTypes,
|
||||||
pixelsPerSecond,
|
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,
|
getTimelineZoomMin,
|
||||||
getTimelinePaddingPx,
|
getTimelinePaddingPx,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
|
||||||
import {
|
import {
|
||||||
getTrackHeight,
|
getTrackHeight,
|
||||||
getCumulativeHeightBefore,
|
getCumulativeHeightBefore,
|
||||||
|
|
@ -58,7 +58,6 @@ import {
|
||||||
} from "./track-layout";
|
} from "./track-layout";
|
||||||
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
|
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
|
||||||
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
|
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
|
||||||
import { isMainTrack } from "@/lib/timeline/placement";
|
|
||||||
import { TimelineToolbar } from "./timeline-toolbar";
|
import { TimelineToolbar } from "./timeline-toolbar";
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
||||||
|
|
@ -112,7 +111,15 @@ export function Timeline() {
|
||||||
} = useElementSelection();
|
} = useElementSelection();
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const timeline = editor.timeline;
|
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 seek = (time: number) => editor.playback.seek({ time });
|
||||||
|
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -340,8 +347,7 @@ export function Timeline() {
|
||||||
|
|
||||||
const containerWidth =
|
const containerWidth =
|
||||||
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
|
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
|
||||||
const contentWidth =
|
const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel });
|
||||||
timelineDuration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
|
||||||
const paddingPx = getTimelinePaddingPx({
|
const paddingPx = getTimelinePaddingPx({
|
||||||
containerWidth,
|
containerWidth,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|
@ -425,13 +431,13 @@ export function Timeline() {
|
||||||
/>
|
/>
|
||||||
<DragLine
|
<DragLine
|
||||||
dropTarget={dropTarget}
|
dropTarget={dropTarget}
|
||||||
tracks={timeline.getTracks()}
|
tracks={tracks}
|
||||||
isVisible={isDragOver && !dropTarget?.targetElement}
|
isVisible={isDragOver && !dropTarget?.targetElement}
|
||||||
headerHeight={timelineHeaderHeight}
|
headerHeight={timelineHeaderHeight}
|
||||||
/>
|
/>
|
||||||
<DragLine
|
<DragLine
|
||||||
dropTarget={dragDropTarget}
|
dropTarget={dragDropTarget}
|
||||||
tracks={timeline.getTracks()}
|
tracks={tracks}
|
||||||
isVisible={dragState.isDragging}
|
isVisible={dragState.isDragging}
|
||||||
headerHeight={timelineHeaderHeight}
|
headerHeight={timelineHeaderHeight}
|
||||||
/>
|
/>
|
||||||
|
|
@ -509,6 +515,7 @@ export function Timeline() {
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<TimelineTrackRows
|
<TimelineTrackRows
|
||||||
dragElementId={dragState.elementId}
|
dragElementId={dragState.elementId}
|
||||||
|
mainTrackId={mainTrackId}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
dragState={dragState}
|
dragState={dragState}
|
||||||
tracksScrollRef={tracksScrollRef}
|
tracksScrollRef={tracksScrollRef}
|
||||||
|
|
@ -575,7 +582,14 @@ function TrackLabelsPanel({
|
||||||
hasHorizontalScrollbar: boolean;
|
hasHorizontalScrollbar: boolean;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
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 { selectedElements } = useElementSelection();
|
||||||
const tracksWithSelection = useMemo(
|
const tracksWithSelection = useMemo(
|
||||||
() => new Set(selectedElements.map((el) => el.trackId)),
|
() => new Set(selectedElements.map((el) => el.trackId)),
|
||||||
|
|
@ -651,6 +665,7 @@ function TrackLabelsPanel({
|
||||||
|
|
||||||
function TimelineTrackRows({
|
function TimelineTrackRows({
|
||||||
dragElementId,
|
dragElementId,
|
||||||
|
mainTrackId,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
dragState,
|
dragState,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
|
@ -666,6 +681,7 @@ function TimelineTrackRows({
|
||||||
dropTarget,
|
dropTarget,
|
||||||
}: {
|
}: {
|
||||||
dragElementId: string | null;
|
dragElementId: string | null;
|
||||||
|
mainTrackId: string | null;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
dragState: ElementDragState;
|
dragState: ElementDragState;
|
||||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
|
@ -685,7 +701,14 @@ function TimelineTrackRows({
|
||||||
dropTarget: DropTarget | null;
|
dropTarget: DropTarget | null;
|
||||||
}) {
|
}) {
|
||||||
const timeline = useEditor((e) => e.timeline);
|
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 { selectedElements } = useElementSelection();
|
||||||
const tracksWithSelection = useMemo(
|
const tracksWithSelection = useMemo(
|
||||||
() => new Set(selectedElements.map((el) => el.trackId)),
|
() => new Set(selectedElements.map((el) => el.trackId)),
|
||||||
|
|
@ -780,7 +803,7 @@ function TimelineTrackRows({
|
||||||
? "Show track"
|
? "Show track"
|
||||||
: "Hide track"}
|
: "Hide track"}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{!isMainTrack(track) && (
|
{track.id !== mainTrackId && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={<HugeiconsIcon icon={Delete02Icon} />}
|
icon={<HugeiconsIcon icon={Delete02Icon} />}
|
||||||
onClick={(event: React.MouseEvent) => {
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils";
|
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
|
||||||
import {
|
import {
|
||||||
TIMELINE_CONTENT_TOP_PADDING_PX,
|
TIMELINE_CONTENT_TOP_PADDING_PX,
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
|
|
@ -96,7 +96,6 @@ export function resolveTimelineElementIntersections({
|
||||||
startPos,
|
startPos,
|
||||||
endPos: currentPos,
|
endPos: currentPos,
|
||||||
});
|
});
|
||||||
const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
|
|
||||||
const selectedElements: TimelineElementRef[] = [];
|
const selectedElements: TimelineElementRef[] = [];
|
||||||
|
|
||||||
for (const [trackIndex, track] of tracks.entries()) {
|
for (const [trackIndex, track] of tracks.entries()) {
|
||||||
|
|
@ -109,8 +108,8 @@ export function resolveTimelineElementIntersections({
|
||||||
const elementBottom = elementTop + trackHeight;
|
const elementBottom = elementTop + trackHeight;
|
||||||
|
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
const elementLeft = element.startTime * pixelsPerSecond;
|
const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel });
|
||||||
const elementRight = elementLeft + element.duration * pixelsPerSecond;
|
const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel });
|
||||||
const elementRectangle = {
|
const elementRectangle = {
|
||||||
left: elementLeft,
|
left: elementLeft,
|
||||||
top: elementTop,
|
top: elementTop,
|
||||||
|
|
|
||||||
|
|
@ -292,10 +292,7 @@ export function TimelineElement({
|
||||||
const canToggleCurrentSourceAudio =
|
const canToggleCurrentSourceAudio =
|
||||||
selectedElements.length === 1 &&
|
selectedElements.length === 1 &&
|
||||||
isCurrentElementSelected &&
|
isCurrentElementSelected &&
|
||||||
canToggleSourceAudio({
|
canToggleSourceAudio(element, mediaAsset);
|
||||||
element,
|
|
||||||
mediaAsset,
|
|
||||||
});
|
|
||||||
const sourceAudioLabel =
|
const sourceAudioLabel =
|
||||||
element.type === "video"
|
element.type === "video"
|
||||||
? getSourceAudioActionLabel({ element })
|
? getSourceAudioActionLabel({ element })
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
timelineTimeToSnappedPixels,
|
timelineTimeToSnappedPixels,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import {
|
import {
|
||||||
TIMELINE_SCROLLBAR_SIZE_PX,
|
TIMELINE_SCROLLBAR_SIZE_PX,
|
||||||
|
|
@ -72,10 +73,11 @@ export function TimelinePlayhead({
|
||||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||||
|
|
||||||
event.preventDefault();
|
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 direction = event.key === "ArrowRight" ? 1 : -1;
|
||||||
const now = editor.playback.getCurrentTime();
|
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 });
|
editor.playback.seek({ time: nextTime });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { type JSX, useLayoutEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||||
} from "@/lib/timeline/scale";
|
} from "@/lib/timeline/scale";
|
||||||
|
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
|
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
|
||||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
|
@ -30,17 +32,18 @@ export function TimelineRuler({
|
||||||
handleRulerTrackingMouseDown,
|
handleRulerTrackingMouseDown,
|
||||||
handleRulerMouseDown,
|
handleRulerMouseDown,
|
||||||
}: TimelineRulerProps) {
|
}: 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 pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||||
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
|
const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond;
|
||||||
const effectiveDuration = Math.max(duration, visibleDuration);
|
const effectiveDurationSeconds = Math.max(durationSeconds, visibleDurationSeconds);
|
||||||
const fps =
|
const fps =
|
||||||
useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
|
useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
|
||||||
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
|
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
fps,
|
fps,
|
||||||
});
|
});
|
||||||
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
|
const tickCount = Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1;
|
||||||
|
|
||||||
const { scrollLeft, viewportWidth } = useScrollPosition({
|
const { scrollLeft, viewportWidth } = useScrollPosition({
|
||||||
scrollRef: tracksScrollRef,
|
scrollRef: tracksScrollRef,
|
||||||
|
|
@ -61,20 +64,20 @@ export function TimelineRuler({
|
||||||
prevZoomRef.current = zoomLevel;
|
prevZoomRef.current = zoomLevel;
|
||||||
}, [zoomLevel]);
|
}, [zoomLevel]);
|
||||||
|
|
||||||
const visibleStartTime = Math.max(
|
const visibleStartTimeSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
(scrollLeft - bufferPx) / pixelsPerSecond,
|
(scrollLeft - bufferPx) / pixelsPerSecond,
|
||||||
);
|
);
|
||||||
const visibleEndTime =
|
const visibleEndTimeSeconds =
|
||||||
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
|
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
|
||||||
|
|
||||||
const startTickIndex = Math.max(
|
const startTickIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor(visibleStartTime / tickIntervalSeconds),
|
Math.floor(visibleStartTimeSeconds / tickIntervalSeconds),
|
||||||
);
|
);
|
||||||
const endTickIndex = Math.min(
|
const endTickIndex = Math.min(
|
||||||
tickCount - 1,
|
tickCount - 1,
|
||||||
Math.ceil(visibleEndTime / tickIntervalSeconds),
|
Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds),
|
||||||
);
|
);
|
||||||
|
|
||||||
const timelineTicks: Array<JSX.Element> = [];
|
const timelineTicks: Array<JSX.Element> = [];
|
||||||
|
|
@ -83,14 +86,16 @@ export function TimelineRuler({
|
||||||
tickIndex <= endTickIndex;
|
tickIndex <= endTickIndex;
|
||||||
tickIndex += 1
|
tickIndex += 1
|
||||||
) {
|
) {
|
||||||
const time = tickIndex * tickIntervalSeconds;
|
const timeSeconds = tickIndex * tickIntervalSeconds;
|
||||||
if (time > effectiveDuration) break;
|
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(
|
timelineTicks.push(
|
||||||
<TimelineTick
|
<TimelineTick
|
||||||
key={tickIndex}
|
key={tickIndex}
|
||||||
time={time}
|
time={timeTicks}
|
||||||
|
timeInSeconds={timeSeconds}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
fps={fps}
|
fps={fps}
|
||||||
showLabel={showLabel}
|
showLabel={showLabel}
|
||||||
|
|
@ -104,7 +109,7 @@ export function TimelineRuler({
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Timeline ruler"
|
aria-label="Timeline ruler"
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={effectiveDuration}
|
aria-valuemax={effectiveDurationSeconds}
|
||||||
aria-valuenow={0}
|
aria-valuenow={0}
|
||||||
className="relative flex-1 overflow-x-visible"
|
className="relative flex-1 overflow-x-visible"
|
||||||
style={{ height: TIMELINE_RULER_HEIGHT_PX }}
|
style={{ height: TIMELINE_RULER_HEIGHT_PX }}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,42 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
|
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||||
|
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
|
||||||
interface TimelineTickProps {
|
|
||||||
time: number;
|
interface TimelineTickProps {
|
||||||
zoomLevel: number;
|
time: number;
|
||||||
fps: number;
|
timeInSeconds: number;
|
||||||
showLabel: boolean;
|
zoomLevel: number;
|
||||||
}
|
fps: FrameRate;
|
||||||
|
showLabel: boolean;
|
||||||
export function TimelineTick({
|
}
|
||||||
time,
|
|
||||||
zoomLevel,
|
export function TimelineTick({
|
||||||
fps,
|
time,
|
||||||
showLabel,
|
timeInSeconds,
|
||||||
}: TimelineTickProps) {
|
zoomLevel,
|
||||||
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
|
fps,
|
||||||
|
showLabel,
|
||||||
if (showLabel) {
|
}: TimelineTickProps) {
|
||||||
const label = formatRulerLabel({ timeInSeconds: time, fps });
|
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
|
||||||
return (
|
|
||||||
<span
|
if (showLabel) {
|
||||||
className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
|
const label = formatRulerLabel({ timeInSeconds, fps });
|
||||||
style={{ left: `${leftPosition}px` }}
|
return (
|
||||||
>
|
<span
|
||||||
{label}
|
className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
|
||||||
</span>
|
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` }}
|
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,
|
SplitButtonSeparator,
|
||||||
} from "@/components/ui/split-button";
|
} from "@/components/ui/split-button";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import {
|
import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
|
||||||
TIMELINE_ZOOM_BUTTON_FACTOR,
|
|
||||||
} from "./interaction";
|
|
||||||
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
|
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
|
||||||
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||||
import { ScenesView } from "@/components/editor/scenes-view";
|
import { ScenesView } from "@/components/editor/scenes-view";
|
||||||
|
|
@ -36,7 +34,6 @@ import {
|
||||||
SnowIcon,
|
SnowIcon,
|
||||||
ScissorIcon,
|
ScissorIcon,
|
||||||
MagnetIcon,
|
MagnetIcon,
|
||||||
Link04Icon,
|
|
||||||
SearchAddIcon,
|
SearchAddIcon,
|
||||||
SearchMinusIcon,
|
SearchMinusIcon,
|
||||||
Copy01Icon,
|
Copy01Icon,
|
||||||
|
|
@ -49,6 +46,9 @@ import {
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { OcRippleIcon } from "@/components/icons";
|
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({
|
export function TimelineToolbar({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|
@ -62,10 +62,7 @@ export function TimelineToolbar({
|
||||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||||
const newZoomLevel =
|
const newZoomLevel =
|
||||||
direction === "in"
|
direction === "in"
|
||||||
? Math.min(
|
? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
|
||||||
TIMELINE_ZOOM_MAX,
|
|
||||||
zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR,
|
|
||||||
)
|
|
||||||
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
|
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
|
||||||
setZoomLevel({ zoom: newZoomLevel });
|
setZoomLevel({ zoom: newZoomLevel });
|
||||||
};
|
};
|
||||||
|
|
@ -90,8 +87,11 @@ export function TimelineToolbar({
|
||||||
|
|
||||||
function ToolbarLeftSection() {
|
function ToolbarLeftSection() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets());
|
const mediaAssets = useEditor((currentEditor) =>
|
||||||
|
currentEditor.media.getAssets(),
|
||||||
|
);
|
||||||
const { selectedElements } = useElementSelection();
|
const { selectedElements } = useElementSelection();
|
||||||
|
const graphEditor = useGraphEditorController();
|
||||||
const isCurrentlyBookmarked = useEditor((e) =>
|
const isCurrentlyBookmarked = useEditor((e) =>
|
||||||
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
|
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
|
||||||
);
|
);
|
||||||
|
|
@ -115,10 +115,7 @@ function ToolbarLeftSection() {
|
||||||
})();
|
})();
|
||||||
const canToggleSelectedSourceAudio =
|
const canToggleSelectedSourceAudio =
|
||||||
!!selectedElement &&
|
!!selectedElement &&
|
||||||
canToggleSourceAudio({
|
canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
|
||||||
element: selectedElement.element,
|
|
||||||
mediaAsset: selectedMediaAsset,
|
|
||||||
});
|
|
||||||
const sourceAudioLabel =
|
const sourceAudioLabel =
|
||||||
selectedElement?.element.type === "video"
|
selectedElement?.element.type === "video"
|
||||||
? getSourceAudioActionLabel({
|
? getSourceAudioActionLabel({
|
||||||
|
|
@ -166,11 +163,11 @@ function ToolbarLeftSection() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={
|
icon={
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon
|
||||||
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
tooltip={sourceAudioLabel}
|
tooltip={sourceAudioLabel}
|
||||||
disabled={!canToggleSelectedSourceAudio}
|
disabled={!canToggleSelectedSourceAudio}
|
||||||
onClick={({ event }) =>
|
onClick={({ event }) =>
|
||||||
|
|
@ -214,13 +211,35 @@ function ToolbarLeftSection() {
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</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
|
<ToolbarButton
|
||||||
icon={<HugeiconsIcon icon={Chart03Icon} />}
|
icon={<HugeiconsIcon icon={Chart03Icon} />}
|
||||||
tooltip="Open graph editor"
|
tooltip={graphEditor.tooltip}
|
||||||
onClick={() => {}}
|
disabled={!graphEditor.canOpen}
|
||||||
|
buttonWrapper={(button) =>
|
||||||
|
graphEditor.canOpen ? (
|
||||||
|
<PopoverTrigger asChild>{button}</PopoverTrigger>
|
||||||
|
) : (
|
||||||
|
button
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</GraphEditorPopover>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -317,29 +336,40 @@ function ToolbarButton({
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
isActive,
|
isActive,
|
||||||
|
buttonWrapper,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
onClick?: ({ event }: { event: React.MouseEvent }) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isActive?: 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 (
|
return (
|
||||||
<Tooltip delayDuration={200}>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||||
<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>
|
|
||||||
<TooltipContent>{tooltip}</TooltipContent>
|
<TooltipContent>{tooltip}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { EditorCore } from "@/core";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
||||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||||
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||||
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
||||||
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
|
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
|
||||||
|
|
@ -117,6 +118,13 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||||
|
|
||||||
function EditorRuntimeBindings() {
|
function EditorRuntimeBindings() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
const rippleEditingEnabled = useTimelineStore(
|
||||||
|
(state) => state.rippleEditingEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editor.command.isRippleEnabled = rippleEditingEnabled;
|
||||||
|
}, [editor, rippleEditingEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const contextMenuItemVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|
@ -94,7 +94,7 @@ const ContextMenuSubContent = React.forwardRef<
|
||||||
<ContextMenuPrimitive.SubContent
|
<ContextMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -112,7 +112,7 @@ const ContextMenuContent = React.forwardRef<
|
||||||
<ContextMenuPrimitive.Content
|
<ContextMenuPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -155,7 +155,7 @@ const ContextMenuItem = React.forwardRef<
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{icon && (
|
{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}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -242,7 +242,7 @@ const ContextMenuLabel = React.forwardRef<
|
||||||
<ContextMenuPrimitive.Label
|
<ContextMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|
@ -262,7 +262,7 @@ const ContextMenuSeparator = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.Separator
|
<ContextMenuPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("bg-border my-2 h-px", className)}
|
className={cn("bg-border mx-1 my-1.5 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -1,293 +1,293 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { useOverlayOpenChange } from "./use-overlay-open-change";
|
import { useOverlayOpenChange } from "./use-overlay-open-change";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
const handleOpenChange = useOverlayOpenChange({
|
const handleOpenChange = useOverlayOpenChange({
|
||||||
source: "dropdown-menu",
|
source: "dropdown-menu",
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Root
|
<DropdownMenuPrimitive.Root
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const dropdownMenuItemVariants = cva(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "",
|
default: "",
|
||||||
destructive:
|
destructive:
|
||||||
"text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
|
"text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
dropdownMenuItemVariants({ variant }),
|
dropdownMenuItemVariants({ variant }),
|
||||||
"data-[state=open]:bg-muted data-[state=open]:text-foreground",
|
"data-[state=open]:bg-muted data-[state=open]:text-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto" />
|
<ChevronRight className="ml-auto" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
DropdownMenuSubTrigger.displayName =
|
DropdownMenuSubTrigger.displayName =
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
|
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
DropdownMenuSubContent.displayName =
|
DropdownMenuSubContent.displayName =
|
||||||
DropdownMenuPrimitive.SubContent.displayName;
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
onCloseAutoFocus={(e) => {
|
onCloseAutoFocus={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg",
|
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
));
|
));
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
icon,
|
icon,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
children,
|
children,
|
||||||
asChild,
|
asChild,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const iconSlot = (
|
const iconSlot = (
|
||||||
<span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex">
|
<span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderedChildren =
|
const renderedChildren =
|
||||||
asChild && React.isValidElement(children) ? (
|
asChild && React.isValidElement(children) ? (
|
||||||
React.cloneElement(
|
React.cloneElement(
|
||||||
children as React.ReactElement<{ children?: React.ReactNode }>,
|
children as React.ReactElement<{ children?: React.ReactNode }>,
|
||||||
{},
|
{},
|
||||||
iconSlot,
|
iconSlot,
|
||||||
(children as React.ReactElement<{ children?: React.ReactNode }>).props
|
(children as React.ReactElement<{ children?: React.ReactNode }>).props
|
||||||
.children,
|
.children,
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{iconSlot}
|
{iconSlot}
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
asChild={asChild}
|
asChild={asChild}
|
||||||
data-has-icon={icon ? "" : undefined}
|
data-has-icon={icon ? "" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
dropdownMenuItemVariants({ variant }),
|
dropdownMenuItemVariants({ variant }),
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{renderedChildren}
|
{renderedChildren}
|
||||||
</DropdownMenuPrimitive.Item>
|
</DropdownMenuPrimitive.Item>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
dropdownMenuItemVariants({ variant }),
|
dropdownMenuItemVariants({ variant }),
|
||||||
"pr-8 pl-2",
|
"pr-8 pl-2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Check className="size-4" />
|
<Check className="size-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
));
|
));
|
||||||
|
|
||||||
DropdownMenuCheckboxItem.displayName =
|
DropdownMenuCheckboxItem.displayName =
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
dropdownMenuItemVariants({ variant }),
|
dropdownMenuItemVariants({ variant }),
|
||||||
"pr-2 pl-8",
|
"pr-2 pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Circle className="size-2 fill-current" />
|
<Circle className="size-2 fill-current" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
));
|
));
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
|
"px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("bg-border/60 mx-1 my-2 h-px", className)}
|
className={cn("bg-border mx-1 my-1.5 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
const DropdownMenuShortcut = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef<
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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" &&
|
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" &&
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -72,7 +72,7 @@ const TabsContent = React.forwardRef<
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
variant === "underline" && "px-4",
|
||||||
className,
|
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 { SelectionManager } from "./managers/selection-manager";
|
||||||
import { registerDefaultEffects } from "@/lib/effects";
|
import { registerDefaultEffects } from "@/lib/effects";
|
||||||
import { registerDefaultMasks } from "@/lib/masks";
|
import { registerDefaultMasks } from "@/lib/masks";
|
||||||
import { isMainTrack } from "@/lib/timeline/placement";
|
|
||||||
|
|
||||||
export class EditorCore {
|
export class EditorCore {
|
||||||
private static instance: EditorCore | null = null;
|
private static instance: EditorCore | null = null;
|
||||||
|
|
@ -39,11 +38,21 @@ export class EditorCore {
|
||||||
this.audio = new AudioManager(this);
|
this.audio = new AudioManager(this);
|
||||||
this.selection = new SelectionManager(this);
|
this.selection = new SelectionManager(this);
|
||||||
this.command.registerReactor(() => {
|
this.command.registerReactor(() => {
|
||||||
const tracks = this.timeline.getTracks();
|
const activeScene = this.scenes.getActiveSceneOrNull();
|
||||||
const prunedTracks = tracks.filter(
|
if (!activeScene) {
|
||||||
(track) => track.elements.length > 0 || isMainTrack(track),
|
return;
|
||||||
);
|
}
|
||||||
if (prunedTracks.length !== tracks.length) {
|
|
||||||
|
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);
|
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 { EditorCore } from "@/core";
|
||||||
import type { Command, CommandResult } from "@/lib/commands";
|
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 {
|
interface CommandHistoryEntry {
|
||||||
command: Command;
|
command: Command;
|
||||||
|
|
@ -9,6 +10,7 @@ interface CommandHistoryEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CommandManager {
|
export class CommandManager {
|
||||||
|
public isRippleEnabled = false;
|
||||||
private history: CommandHistoryEntry[] = [];
|
private history: CommandHistoryEntry[] = [];
|
||||||
private redoStack: CommandHistoryEntry[] = [];
|
private redoStack: CommandHistoryEntry[] = [];
|
||||||
private reactors: Array<() => void> = [];
|
private reactors: Array<() => void> = [];
|
||||||
|
|
@ -16,8 +18,12 @@ export class CommandManager {
|
||||||
constructor(private editor: EditorCore) {}
|
constructor(private editor: EditorCore) {}
|
||||||
|
|
||||||
execute({ command }: { command: Command }): Command {
|
execute({ command }: { command: Command }): Command {
|
||||||
|
const beforeTracks = this.isRippleEnabled
|
||||||
|
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
||||||
|
: null;
|
||||||
const previousSelection = this.getSelectionSnapshot();
|
const previousSelection = this.getSelectionSnapshot();
|
||||||
const result = command.execute();
|
const result = command.execute();
|
||||||
|
this.applyRippleIfEnabled({ beforeTracks });
|
||||||
const selectionOverride = this.applySelectionOverride(result);
|
const selectionOverride = this.applySelectionOverride(result);
|
||||||
this.runReactors();
|
this.runReactors();
|
||||||
this.history.push({
|
this.history.push({
|
||||||
|
|
@ -67,8 +73,12 @@ export class CommandManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const beforeTracks = this.isRippleEnabled
|
||||||
|
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
||||||
|
: null;
|
||||||
const previousSelection = this.getSelectionSnapshot();
|
const previousSelection = this.getSelectionSnapshot();
|
||||||
const result = entry.command.redo();
|
const result = entry.command.redo();
|
||||||
|
this.applyRippleIfEnabled({ beforeTracks });
|
||||||
const selectionOverride = this.applySelectionOverride(result);
|
const selectionOverride = this.applySelectionOverride(result);
|
||||||
this.runReactors();
|
this.runReactors();
|
||||||
|
|
||||||
|
|
@ -113,4 +123,32 @@ export class CommandManager {
|
||||||
reactor();
|
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 type { EditorCore } from "@/core";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
|
import { roundToFrame } from "opencut-wasm";
|
||||||
|
|
||||||
export class PlaybackManager {
|
export class PlaybackManager {
|
||||||
private isPlaying = false;
|
private isPlaying = false;
|
||||||
|
|
@ -9,11 +11,12 @@ export class PlaybackManager {
|
||||||
private isScrubbing = false;
|
private isScrubbing = false;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private playbackTimer: number | null = null;
|
private playbackTimer: number | null = null;
|
||||||
private lastUpdate = 0;
|
private playbackStartWallTime = 0;
|
||||||
|
private playbackStartTime = 0;
|
||||||
|
|
||||||
constructor(private editor: EditorCore) {
|
constructor(private editor: EditorCore) {
|
||||||
this.editor.timeline.subscribe(() => {
|
this.editor.timeline.subscribe(() => {
|
||||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
if (this.currentTime > maxTime && maxTime > 0) {
|
if (this.currentTime > maxTime && maxTime > 0) {
|
||||||
this.currentTime = maxTime;
|
this.currentTime = maxTime;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
@ -22,7 +25,7 @@ export class PlaybackManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
play(): void {
|
play(): void {
|
||||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
|
|
||||||
if (maxTime > 0) {
|
if (maxTime > 0) {
|
||||||
if (this.currentTime >= maxTime) {
|
if (this.currentTime >= maxTime) {
|
||||||
|
|
@ -50,8 +53,12 @@ export class PlaybackManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
seek({ time }: { time: number }): void {
|
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));
|
this.currentTime = Math.max(0, Math.min(maxTime, time));
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.playbackStartWallTime = performance.now();
|
||||||
|
this.playbackStartTime = this.currentTime;
|
||||||
|
}
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
|
|
@ -135,7 +142,8 @@ export class PlaybackManager {
|
||||||
cancelAnimationFrame(this.playbackTimer);
|
cancelAnimationFrame(this.playbackTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastUpdate = performance.now();
|
this.playbackStartWallTime = performance.now();
|
||||||
|
this.playbackStartTime = this.currentTime;
|
||||||
this.updateTime();
|
this.updateTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,12 +157,11 @@ export class PlaybackManager {
|
||||||
private updateTime = (): void => {
|
private updateTime = (): void => {
|
||||||
if (!this.isPlaying) return;
|
if (!this.isPlaying) return;
|
||||||
|
|
||||||
const now = performance.now();
|
const fps = this.editor.project.getActive()?.settings.fps;
|
||||||
const delta = (now - this.lastUpdate) / 1000;
|
const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000;
|
||||||
this.lastUpdate = now;
|
const rawTime = this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
|
||||||
|
const newTime = fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) : rawTime;
|
||||||
const newTime = this.currentTime + delta;
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
|
||||||
|
|
||||||
if (maxTime > 0 && newTime >= maxTime) {
|
if (maxTime > 0 && newTime >= maxTime) {
|
||||||
this.pause();
|
this.pause();
|
||||||
|
|
|
||||||
|
|
@ -156,9 +156,14 @@ export class ProjectManager {
|
||||||
|
|
||||||
await this.editor.media.loadProjectMedia({ projectId: id });
|
await this.editor.media.loadProjectMedia({ projectId: id });
|
||||||
|
|
||||||
const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
|
|
||||||
await loadFonts({
|
await loadFonts({
|
||||||
families: getElementFontFamilies({ tracks: allTracks }),
|
families: [
|
||||||
|
...new Set(
|
||||||
|
(project.scenes ?? []).flatMap((scene) =>
|
||||||
|
getElementFontFamilies({ tracks: scene.tracks }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project.metadata.thumbnail) {
|
if (!project.metadata.thumbnail) {
|
||||||
|
|
@ -496,7 +501,7 @@ export class ProjectManager {
|
||||||
importedAssets,
|
importedAssets,
|
||||||
}: {
|
}: {
|
||||||
importedAssets: Array<Pick<MediaAsset, "type" | "fps">>;
|
importedAssets: Array<Pick<MediaAsset, "type" | "fps">>;
|
||||||
}): number | null {
|
}): import("opencut-wasm").FrameRate | null {
|
||||||
if (!this.active) return null;
|
if (!this.active) return null;
|
||||||
|
|
||||||
const nextFps = getRaisedProjectFpsForImportedMedia({
|
const nextFps = getRaisedProjectFpsForImportedMedia({
|
||||||
|
|
@ -643,7 +648,7 @@ export class ProjectManager {
|
||||||
private async updateThumbnailFromTimeline(): Promise<boolean> {
|
private async updateThumbnailFromTimeline(): Promise<boolean> {
|
||||||
if (!this.active) return false;
|
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 mediaAssets = this.editor.media.getAssets();
|
||||||
const duration = this.editor.timeline.getTotalDuration();
|
const duration = this.editor.timeline.getTotalDuration();
|
||||||
const { canvasSize, background } = this.active.settings;
|
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 { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||||
import { buildScene } from "@/services/renderer/scene-builder";
|
import { buildScene } from "@/services/renderer/scene-builder";
|
||||||
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
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";
|
import { downloadBlob } from "@/utils/browser";
|
||||||
|
|
||||||
type SnapshotResult =
|
type SnapshotResult =
|
||||||
|
|
@ -81,7 +82,10 @@ export class RendererManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canvasSize, fps } = activeProject.settings;
|
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({
|
const renderer = new CanvasRenderer({
|
||||||
width: canvasSize.width,
|
width: canvasSize.width,
|
||||||
|
|
@ -107,7 +111,7 @@ export class RendererManager {
|
||||||
return { success: false, error: "Failed to create image" };
|
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 =
|
const safeName =
|
||||||
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
|
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
|
||||||
"snapshot";
|
"snapshot";
|
||||||
|
|
@ -135,7 +139,7 @@ export class RendererManager {
|
||||||
const { format, quality, fps, includeAudio } = options;
|
const { format, quality, fps, includeAudio } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tracks = this.editor.timeline.getTracks();
|
const tracks = this.editor.scenes.getActiveScene().tracks;
|
||||||
const mediaAssets = this.editor.media.getAssets();
|
const mediaAssets = this.editor.media.getAssets();
|
||||||
const activeProject = this.editor.project.getActive();
|
const activeProject = this.editor.project.getActive();
|
||||||
|
|
||||||
|
|
@ -148,7 +152,7 @@ export class RendererManager {
|
||||||
return { success: false, error: "Project is empty" };
|
return { success: false, error: "Project is empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportFps = fps || activeProject.settings.fps;
|
const exportFps = fps ?? activeProject.settings.fps;
|
||||||
const canvasSize = activeProject.settings.canvasSize;
|
const canvasSize = activeProject.settings.canvasSize;
|
||||||
|
|
||||||
let audioBuffer: AudioBuffer | null = null;
|
let audioBuffer: AudioBuffer | null = null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { EditorCore } from "@/core";
|
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 { storageService } from "@/services/storage/service";
|
||||||
import {
|
import {
|
||||||
getMainScene,
|
getMainScene,
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
isBookmarkAtTime,
|
isBookmarkAtTime,
|
||||||
} from "@/lib/timeline/bookmarks";
|
} from "@/lib/timeline/bookmarks";
|
||||||
import { ensureMainTrack } from "@/lib/timeline/placement";
|
|
||||||
import {
|
import {
|
||||||
CreateSceneCommand,
|
CreateSceneCommand,
|
||||||
DeleteSceneCommand,
|
DeleteSceneCommand,
|
||||||
|
|
@ -174,10 +173,7 @@ export class ScenesManager {
|
||||||
try {
|
try {
|
||||||
const result = await storageService.loadProject({ id: projectId });
|
const result = await storageService.loadProject({ id: projectId });
|
||||||
if (result?.project.scenes) {
|
if (result?.project.scenes) {
|
||||||
const { scenes: ensuredScenes, hasAddedMainTrack } =
|
const ensuredScenes = result.project.scenes ?? [];
|
||||||
this.ensureScenesHaveMainTrack({
|
|
||||||
scenes: result.project.scenes ?? [],
|
|
||||||
});
|
|
||||||
const currentScene = findCurrentScene({
|
const currentScene = findCurrentScene({
|
||||||
scenes: ensuredScenes,
|
scenes: ensuredScenes,
|
||||||
currentSceneId: result.project.currentSceneId,
|
currentSceneId: result.project.currentSceneId,
|
||||||
|
|
@ -186,22 +182,6 @@ export class ScenesManager {
|
||||||
this.list = ensuredScenes;
|
this.list = ensuredScenes;
|
||||||
this.active = currentScene;
|
this.active = currentScene;
|
||||||
this.notify();
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to load project scenes:", error);
|
console.error("Failed to load project scenes:", error);
|
||||||
|
|
@ -219,26 +199,24 @@ export class ScenesManager {
|
||||||
currentSceneId?: string;
|
currentSceneId?: string;
|
||||||
}): void {
|
}): void {
|
||||||
const ensuredScenes = ensureMainScene({ scenes });
|
const ensuredScenes = ensureMainScene({ scenes });
|
||||||
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
|
|
||||||
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
|
|
||||||
const currentScene = currentSceneId
|
const currentScene = currentSceneId
|
||||||
? scenesWithMainTracks.find((s) => s.id === currentSceneId)
|
? ensuredScenes.find((s) => s.id === currentSceneId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
|
const fallbackScene = getMainScene({ scenes: ensuredScenes });
|
||||||
|
|
||||||
this.list = scenesWithMainTracks;
|
this.list = ensuredScenes;
|
||||||
this.active = currentScene || fallbackScene;
|
this.active = currentScene || fallbackScene;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
const hasAddedMainScene = ensuredScenes.length > scenes.length;
|
const hasAddedMainScene = ensuredScenes.length > scenes.length;
|
||||||
if (hasAddedMainScene || hasAddedMainTrack) {
|
if (hasAddedMainScene) {
|
||||||
const activeProject = this.editor.project.getActive();
|
const activeProject = this.editor.project.getActive();
|
||||||
|
|
||||||
if (activeProject) {
|
if (activeProject) {
|
||||||
const updatedProject = {
|
const updatedProject = {
|
||||||
...activeProject,
|
...activeProject,
|
||||||
scenes: scenesWithMainTracks,
|
scenes: ensuredScenes,
|
||||||
metadata: {
|
metadata: {
|
||||||
...activeProject.metadata,
|
...activeProject.metadata,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|
@ -264,6 +242,10 @@ export class ScenesManager {
|
||||||
return this.active;
|
return this.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveSceneOrNull(): TScene | null {
|
||||||
|
return this.active;
|
||||||
|
}
|
||||||
|
|
||||||
getScenes(): TScene[] {
|
getScenes(): TScene[] {
|
||||||
return this.list;
|
return this.list;
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +289,7 @@ export class ScenesManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
|
updateSceneTracks({ tracks }: { tracks: SceneTracks }): void {
|
||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
|
|
||||||
const updatedScene: TScene = {
|
const updatedScene: TScene = {
|
||||||
|
|
@ -335,29 +317,4 @@ export class ScenesManager {
|
||||||
this.editor.project.setActiveProject({ project: updatedProject });
|
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 { EditorCore } from "@/core";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { ParamValues } from "@/lib/params";
|
||||||
import type {
|
import type {
|
||||||
|
SceneTracks,
|
||||||
TrackType,
|
TrackType,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
ClipboardItem,
|
ClipboardItem,
|
||||||
RetimeConfig,
|
RetimeConfig,
|
||||||
} from "@/lib/timeline";
|
} 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 {
|
import type {
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
|
ScalarCurveKeyframePatch,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { calculateTotalDuration } from "@/lib/timeline";
|
import { lastFrameTime } from "opencut-wasm";
|
||||||
import { getLastFrameTime } from "opencut-wasm";
|
import { BatchCommand } from "@/lib/commands";
|
||||||
import {
|
import {
|
||||||
AddTrackCommand,
|
AddTrackCommand,
|
||||||
RemoveTrackCommand,
|
RemoveTrackCommand,
|
||||||
ToggleTrackMuteCommand,
|
ToggleTrackMuteCommand,
|
||||||
ToggleTrackVisibilityCommand,
|
ToggleTrackVisibilityCommand,
|
||||||
InsertElementCommand,
|
InsertElementCommand,
|
||||||
UpdateElementTrimCommand,
|
|
||||||
UpdateElementDurationCommand,
|
|
||||||
DeleteElementsCommand,
|
DeleteElementsCommand,
|
||||||
DuplicateElementsCommand,
|
DuplicateElementsCommand,
|
||||||
ToggleElementsVisibilityCommand,
|
UpdateElementsCommand,
|
||||||
ToggleElementsMutedCommand,
|
|
||||||
UpdateElementCommand,
|
|
||||||
SplitElementsCommand,
|
SplitElementsCommand,
|
||||||
PasteCommand,
|
PasteCommand,
|
||||||
UpdateElementStartTimeCommand,
|
|
||||||
MoveElementCommand,
|
MoveElementCommand,
|
||||||
TracksSnapshotCommand,
|
TracksSnapshotCommand,
|
||||||
UpdateElementRetimeCommand,
|
|
||||||
UpsertKeyframeCommand,
|
UpsertKeyframeCommand,
|
||||||
RemoveKeyframeCommand,
|
RemoveKeyframeCommand,
|
||||||
RetimeKeyframeCommand,
|
RetimeKeyframeCommand,
|
||||||
|
UpdateScalarKeyframeCurveCommand,
|
||||||
AddClipEffectCommand,
|
AddClipEffectCommand,
|
||||||
RemoveClipEffectCommand,
|
RemoveClipEffectCommand,
|
||||||
UpdateClipEffectParamsCommand,
|
UpdateClipEffectParamsCommand,
|
||||||
|
|
@ -47,13 +53,12 @@ import {
|
||||||
RemoveEffectParamKeyframeCommand,
|
RemoveEffectParamKeyframeCommand,
|
||||||
ToggleSourceAudioSeparationCommand,
|
ToggleSourceAudioSeparationCommand,
|
||||||
} from "@/lib/commands/timeline";
|
} from "@/lib/commands/timeline";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
|
||||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||||
|
|
||||||
export class TimelineManager {
|
export class TimelineManager {
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private previewOverlay = new Map<string, Partial<TimelineElement>>();
|
private previewOverlay = new Map<string, Partial<TimelineElement>>();
|
||||||
private previewTracks: TimelineTrack[] | null = null;
|
private previewTracks: SceneTracks | null = null;
|
||||||
|
|
||||||
constructor(private editor: EditorCore) {}
|
constructor(private editor: EditorCore) {}
|
||||||
|
|
||||||
|
|
@ -80,7 +85,6 @@ export class TimelineManager {
|
||||||
startTime,
|
startTime,
|
||||||
duration,
|
duration,
|
||||||
pushHistory = true,
|
pushHistory = true,
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
trimStart: number;
|
trimStart: number;
|
||||||
|
|
@ -88,44 +92,33 @@ export class TimelineManager {
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
pushHistory?: boolean;
|
pushHistory?: boolean;
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}): void {
|
}): void {
|
||||||
const command = new UpdateElementTrimCommand({
|
const trackId = this.findTrackIdForElement({ elementId });
|
||||||
elementId,
|
if (!trackId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUpdates: Partial<TimelineElement> = {
|
||||||
trimStart,
|
trimStart,
|
||||||
trimEnd,
|
trimEnd,
|
||||||
startTime,
|
};
|
||||||
duration,
|
if (startTime !== undefined) {
|
||||||
rippleEnabled,
|
nextUpdates.startTime = startTime;
|
||||||
});
|
}
|
||||||
if (pushHistory) {
|
if (duration !== undefined) {
|
||||||
this.editor.command.execute({ command });
|
nextUpdates.duration = duration;
|
||||||
} else {
|
|
||||||
command.execute();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
updateElementDuration({
|
this.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId,
|
{
|
||||||
duration,
|
trackId,
|
||||||
pushHistory = true,
|
elementId,
|
||||||
}: {
|
patch: nextUpdates,
|
||||||
trackId: string;
|
},
|
||||||
elementId: string;
|
],
|
||||||
duration: number;
|
pushHistory,
|
||||||
pushHistory?: boolean;
|
|
||||||
}): void {
|
|
||||||
const command = new UpdateElementDurationCommand({
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
duration,
|
|
||||||
});
|
});
|
||||||
if (pushHistory) {
|
|
||||||
this.editor.command.execute({ command });
|
|
||||||
} else {
|
|
||||||
command.execute();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateElementRetime({
|
updateElementRetime({
|
||||||
|
|
@ -139,30 +132,18 @@ export class TimelineManager {
|
||||||
retime?: RetimeConfig;
|
retime?: RetimeConfig;
|
||||||
pushHistory?: boolean;
|
pushHistory?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
const command = new UpdateElementRetimeCommand({
|
this.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId,
|
{
|
||||||
retime,
|
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({
|
moveElement({
|
||||||
|
|
@ -171,14 +152,12 @@ export class TimelineManager {
|
||||||
elementId,
|
elementId,
|
||||||
newStartTime,
|
newStartTime,
|
||||||
createTrack,
|
createTrack,
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
sourceTrackId: string;
|
sourceTrackId: string;
|
||||||
targetTrackId: string;
|
targetTrackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
newStartTime: number;
|
newStartTime: number;
|
||||||
createTrack?: { type: TrackType; index: number };
|
createTrack?: { type: TrackType; index: number };
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}): void {
|
}): void {
|
||||||
const command = new MoveElementCommand({
|
const command = new MoveElementCommand({
|
||||||
sourceTrackId,
|
sourceTrackId,
|
||||||
|
|
@ -186,7 +165,6 @@ export class TimelineManager {
|
||||||
elementId,
|
elementId,
|
||||||
newStartTime,
|
newStartTime,
|
||||||
createTrack,
|
createTrack,
|
||||||
rippleEnabled,
|
|
||||||
});
|
});
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
@ -205,36 +183,43 @@ export class TimelineManager {
|
||||||
elements,
|
elements,
|
||||||
splitTime,
|
splitTime,
|
||||||
retainSide = "both",
|
retainSide = "both",
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
splitTime: number;
|
splitTime: number;
|
||||||
retainSide?: "both" | "left" | "right";
|
retainSide?: "both" | "left" | "right";
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}): { trackId: string; elementId: string }[] {
|
}): { trackId: string; elementId: string }[] {
|
||||||
const command = new SplitElementsCommand({
|
const command = new SplitElementsCommand({
|
||||||
elements,
|
elements,
|
||||||
splitTime,
|
splitTime,
|
||||||
retainSide,
|
retainSide,
|
||||||
rippleEnabled,
|
|
||||||
});
|
});
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
return command.getRightSideElements();
|
return command.getRightSideElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
getTotalDuration(): number {
|
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 duration = this.getTotalDuration();
|
||||||
const fps = this.editor.project.getActive()?.settings.fps;
|
const fps = this.editor.project.getActive()?.settings.fps;
|
||||||
if (!fps || duration <= 0) return duration;
|
if (!fps || duration <= 0) return duration;
|
||||||
return getLastFrameTime({ duration, fps });
|
return lastFrameTime({ duration, rate: fps }) ?? duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
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({
|
getElementsWithTracks({
|
||||||
|
|
@ -273,12 +258,10 @@ export class TimelineManager {
|
||||||
|
|
||||||
deleteElements({
|
deleteElements({
|
||||||
elements,
|
elements,
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}): void {
|
}): void {
|
||||||
const command = new DeleteElementsCommand({ elements, rippleEnabled });
|
const command = new DeleteElementsCommand({ elements });
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,20 +286,17 @@ export class TimelineManager {
|
||||||
updates: Array<{
|
updates: Array<{
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
updates: Partial<TimelineElement>;
|
patch: Partial<TimelineElement>;
|
||||||
}>;
|
}>;
|
||||||
pushHistory?: boolean;
|
pushHistory?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
const commands = updates.map(
|
if (updates.length === 0) {
|
||||||
({ trackId, elementId, updates: elementUpdates }) =>
|
return;
|
||||||
new UpdateElementCommand({
|
}
|
||||||
trackId,
|
|
||||||
elementId,
|
const command = new UpdateElementsCommand({
|
||||||
updates: elementUpdates,
|
updates,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
const command =
|
|
||||||
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
|
||||||
if (pushHistory) {
|
if (pushHistory) {
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -549,6 +529,45 @@ export class TimelineManager {
|
||||||
this.editor.command.execute({ command });
|
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({
|
upsertEffectParamKeyframe({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
|
|
@ -625,14 +644,20 @@ export class TimelineManager {
|
||||||
} as Partial<TimelineElement>;
|
} as Partial<TimelineElement>;
|
||||||
this.previewOverlay.set(elementId, mergedOverlay);
|
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.previewTracks = this.applyPreviewOverlay(committedTracks);
|
||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
commitPreview(): void {
|
commitPreview(): void {
|
||||||
if (this.previewOverlay.size === 0) return;
|
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 =
|
const afterTracks =
|
||||||
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
|
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
|
||||||
const command = new TracksSnapshotCommand(committedTracks, afterTracks);
|
const command = new TracksSnapshotCommand(committedTracks, afterTracks);
|
||||||
|
|
@ -649,19 +674,32 @@ export class TimelineManager {
|
||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] {
|
private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
|
||||||
if (this.previewOverlay.size === 0) return tracks;
|
if (this.previewOverlay.size === 0) return tracks;
|
||||||
return tracks.map((track) => {
|
|
||||||
const hasOverlay = track.elements.some((el) =>
|
const applyTrackOverlay = <TTrack extends TimelineTrack>(track: TTrack): TTrack => {
|
||||||
this.previewOverlay.has(el.id),
|
const hasOverlay = track.elements.some((element) =>
|
||||||
|
this.previewOverlay.has(element.id),
|
||||||
);
|
);
|
||||||
if (!hasOverlay) return track;
|
if (!hasOverlay) {
|
||||||
const newElements = track.elements.map((el) => {
|
return track;
|
||||||
const overlay = this.previewOverlay.get(el.id);
|
}
|
||||||
return overlay ? ({ ...el, ...overlay } as TimelineElement) : el;
|
|
||||||
|
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({
|
duplicateElements({
|
||||||
|
|
@ -679,8 +717,27 @@ export class TimelineManager {
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
}): void {
|
}): void {
|
||||||
const command = new ToggleElementsVisibilityCommand(elements);
|
const shouldHide = elements.some(({ trackId, elementId }) => {
|
||||||
this.editor.command.execute({ command });
|
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({
|
toggleElementsMuted({
|
||||||
|
|
@ -688,17 +745,31 @@ export class TimelineManager {
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
}): void {
|
}): void {
|
||||||
const command = new ToggleElementsMutedCommand(elements);
|
const shouldMute = elements.some(({ trackId, elementId }) => {
|
||||||
this.editor.command.execute({ command });
|
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[] {
|
getPreviewTracks(): SceneTracks | null {
|
||||||
return this.editor.scenes.getActiveScene()?.tracks ?? [];
|
return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null;
|
||||||
}
|
|
||||||
|
|
||||||
getRenderTracks(): TimelineTrack[] {
|
|
||||||
if (this.previewTracks !== null) return this.previewTracks;
|
|
||||||
return this.getTracks();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(listener: () => void): () => void {
|
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.previewOverlay.clear();
|
||||||
this.previewTracks = null;
|
this.previewTracks = null;
|
||||||
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
|
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 { useActionHandler } from "@/hooks/actions/use-action-handler";
|
||||||
import { useEditor } from "../use-editor";
|
import { useEditor } from "../use-editor";
|
||||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
||||||
import {
|
import {
|
||||||
getElementsAtTime,
|
getElementsAtTime,
|
||||||
|
|
@ -110,10 +111,11 @@ export function useEditorActions() {
|
||||||
"frame-step-forward",
|
"frame-step-forward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
|
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.min(
|
time: Math.min(
|
||||||
editor.timeline.getTotalDuration(),
|
editor.timeline.getTotalDuration(),
|
||||||
editor.playback.getCurrentTime() + 1 / fps,
|
editor.playback.getCurrentTime() + ticksPerFrame,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -124,8 +126,9 @@ export function useEditorActions() {
|
||||||
"frame-step-backward",
|
"frame-step-backward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
|
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
|
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -176,11 +179,12 @@ export function useEditorActions() {
|
||||||
"split",
|
"split",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -198,11 +202,12 @@ export function useEditorActions() {
|
||||||
"split-left",
|
"split-left",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -212,7 +217,6 @@ export function useEditorActions() {
|
||||||
elements: elementsToSplit,
|
elements: elementsToSplit,
|
||||||
splitTime: currentTime,
|
splitTime: currentTime,
|
||||||
retainSide: "right",
|
retainSide: "right",
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
||||||
|
|
@ -231,11 +235,12 @@ export function useEditorActions() {
|
||||||
"split-right",
|
"split-right",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -263,7 +268,6 @@ export function useEditorActions() {
|
||||||
}
|
}
|
||||||
editor.timeline.deleteElements({
|
editor.timeline.deleteElements({
|
||||||
elements: selectedElements,
|
elements: selectedElements,
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -294,12 +298,7 @@ export function useEditorActions() {
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
if (
|
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
||||||
!canToggleSourceAudio({
|
|
||||||
element: selectedElement.element,
|
|
||||||
mediaAsset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,7 +313,12 @@ export function useEditorActions() {
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"select-all",
|
"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) => ({
|
track.elements.map((element) => ({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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 { 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 { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
||||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
|
|
@ -21,6 +21,7 @@ import { registerCanceller } from "@/lib/cancel-interaction";
|
||||||
import type {
|
import type {
|
||||||
DropTarget,
|
DropTarget,
|
||||||
ElementDragState,
|
ElementDragState,
|
||||||
|
SceneTracks,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
|
|
@ -68,7 +69,8 @@ function getClickOffsetTime({
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
}): number {
|
}): number {
|
||||||
const clickOffsetX = clientX - elementRect.left;
|
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({
|
function getVerticalDragDirection({
|
||||||
|
|
@ -100,7 +102,7 @@ function getDragDropTarget({
|
||||||
clientY: number;
|
clientY: number;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
headerRef?: RefObject<HTMLElement | null>;
|
headerRef?: RefObject<HTMLElement | null>;
|
||||||
|
|
@ -112,7 +114,11 @@ function getDragDropTarget({
|
||||||
const scrollContainer = tracksScrollRef.current;
|
const scrollContainer = tracksScrollRef.current;
|
||||||
if (!containerRect || !scrollContainer) return null;
|
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(
|
const movingElement = sourceTrack?.elements.find(
|
||||||
({ id }) => id === elementId,
|
({ id }) => id === elementId,
|
||||||
);
|
);
|
||||||
|
|
@ -161,9 +167,13 @@ export function useElementInteraction({
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
}: UseElementInteractionProps) {
|
}: UseElementInteractionProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const tracks = editor.timeline.getTracks();
|
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||||
|
const tracks = [
|
||||||
|
...sceneTracks.overlay,
|
||||||
|
sceneTracks.main,
|
||||||
|
...sceneTracks.audio,
|
||||||
|
];
|
||||||
const {
|
const {
|
||||||
isElementSelected,
|
isElementSelected,
|
||||||
selectElement,
|
selectElement,
|
||||||
|
|
@ -242,7 +252,7 @@ export function useElementInteraction({
|
||||||
const startSnap = snapElementEdge({
|
const startSnap = snapElementEdge({
|
||||||
targetTime: frameSnappedTime,
|
targetTime: frameSnappedTime,
|
||||||
elementDuration,
|
elementDuration,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
playheadTime,
|
playheadTime,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
excludeElementId: movingElement.id,
|
excludeElementId: movingElement.id,
|
||||||
|
|
@ -252,7 +262,7 @@ export function useElementInteraction({
|
||||||
const endSnap = snapElementEdge({
|
const endSnap = snapElementEdge({
|
||||||
targetTime: frameSnappedTime,
|
targetTime: frameSnappedTime,
|
||||||
elementDuration,
|
elementDuration,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
playheadTime,
|
playheadTime,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
excludeElementId: movingElement.id,
|
excludeElementId: movingElement.id,
|
||||||
|
|
@ -299,14 +309,17 @@ export function useElementInteraction({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const adjustedTime = Math.max(
|
const adjustedTime = Math.max(
|
||||||
0,
|
0,
|
||||||
mouseTime - pendingDragRef.current.clickOffsetTime,
|
mouseTime - pendingDragRef.current.clickOffsetTime,
|
||||||
);
|
);
|
||||||
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps });
|
const snappedTime = roundToFrame({
|
||||||
startDrag({
|
time: adjustedTime,
|
||||||
...pendingDragRef.current,
|
rate: activeProject.settings.fps,
|
||||||
initialCurrentTime: snappedTime,
|
}) ?? adjustedTime;
|
||||||
|
startDrag({
|
||||||
|
...pendingDragRef.current,
|
||||||
|
initialCurrentTime: snappedTime,
|
||||||
initialCurrentMouseY: clientY,
|
initialCurrentMouseY: clientY,
|
||||||
});
|
});
|
||||||
startedDragThisEvent = true;
|
startedDragThisEvent = true;
|
||||||
|
|
@ -346,7 +359,7 @@ export function useElementInteraction({
|
||||||
});
|
});
|
||||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||||
const fps = activeProject.settings.fps;
|
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 sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
|
||||||
const movingElement = sourceTrack?.elements.find(
|
const movingElement = sourceTrack?.elements.find(
|
||||||
|
|
@ -373,7 +386,7 @@ export function useElementInteraction({
|
||||||
clientY,
|
clientY,
|
||||||
elementId: dragState.elementId,
|
elementId: dragState.elementId,
|
||||||
trackId: dragState.trackId,
|
trackId: dragState.trackId,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
headerRef,
|
headerRef,
|
||||||
|
|
@ -433,7 +446,7 @@ export function useElementInteraction({
|
||||||
clientY,
|
clientY,
|
||||||
elementId: dragState.elementId,
|
elementId: dragState.elementId,
|
||||||
trackId: dragState.trackId,
|
trackId: dragState.trackId,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
headerRef,
|
headerRef,
|
||||||
|
|
@ -457,6 +470,18 @@ export function useElementInteraction({
|
||||||
onSnapPointChange?.(null);
|
onSnapPointChange?.(null);
|
||||||
return;
|
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) {
|
if (dropTarget.isNewTrack) {
|
||||||
const newTrackId = generateUUID();
|
const newTrackId = generateUUID();
|
||||||
|
|
@ -467,7 +492,6 @@ export function useElementInteraction({
|
||||||
elementId: dragState.elementId,
|
elementId: dragState.elementId,
|
||||||
newStartTime: snappedTime,
|
newStartTime: snappedTime,
|
||||||
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
|
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
|
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -478,7 +502,6 @@ export function useElementInteraction({
|
||||||
targetTrackId: targetTrack.id,
|
targetTrackId: targetTrack.id,
|
||||||
elementId: dragState.elementId,
|
elementId: dragState.elementId,
|
||||||
newStartTime: snappedTime,
|
newStartTime: snappedTime,
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
if (targetTrack.id !== dragState.trackId) {
|
if (targetTrack.id !== dragState.trackId) {
|
||||||
selectElement({
|
selectElement({
|
||||||
|
|
@ -509,7 +532,6 @@ export function useElementInteraction({
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
headerRef,
|
headerRef,
|
||||||
rippleEditingEnabled,
|
|
||||||
selectElement,
|
selectElement,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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 type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
|
|
@ -45,9 +46,6 @@ export function useTimelineElementResize({
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||||
const rippleEditingEnabled = useTimelineStore(
|
|
||||||
(state) => state.rippleEditingEnabled,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||||
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
||||||
|
|
@ -188,16 +186,17 @@ export function useTimelineElementResize({
|
||||||
({ clientX }: { clientX: number }) => {
|
({ clientX }: { clientX: number }) => {
|
||||||
if (!resizing) return;
|
if (!resizing) return;
|
||||||
|
|
||||||
const deltaX = clientX - resizing.startX;
|
const deltaX = clientX - resizing.startX;
|
||||||
let deltaTime =
|
let deltaTime = Math.round(
|
||||||
deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND,
|
||||||
let resizeSnapPoint: SnapPoint | null = null;
|
);
|
||||||
|
let resizeSnapPoint: SnapPoint | null = null;
|
||||||
|
|
||||||
const projectFps = editor.project.getActive().settings.fps;
|
const projectFps = editor.project.getActive().settings.fps;
|
||||||
const minDurationSeconds = 1 / projectFps;
|
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||||
if (shouldSnap) {
|
if (shouldSnap) {
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const playheadTime = editor.playback.getCurrentTime();
|
const playheadTime = editor.playback.getCurrentTime();
|
||||||
const snapPoints = findSnapPoints({
|
const snapPoints = findSnapPoints({
|
||||||
tracks,
|
tracks,
|
||||||
|
|
@ -280,19 +279,19 @@ export function useTimelineElementResize({
|
||||||
const maxAllowed =
|
const maxAllowed =
|
||||||
sourceDuration -
|
sourceDuration -
|
||||||
resizing.initialTrimEnd -
|
resizing.initialTrimEnd -
|
||||||
getVisibleSourceSpanForDuration(minDurationSeconds);
|
getVisibleSourceSpanForDuration(minDuration);
|
||||||
const calculated =
|
const calculated =
|
||||||
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
|
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
|
||||||
|
|
||||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
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(
|
const visibleSourceSpan = Math.max(
|
||||||
0,
|
0,
|
||||||
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
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 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);
|
setCurrentTrimStart(newTrimStart);
|
||||||
setCurrentStartTime(newStartTime);
|
setCurrentStartTime(newStartTime);
|
||||||
|
|
@ -314,8 +313,8 @@ export function useTimelineElementResize({
|
||||||
)
|
)
|
||||||
: Math.min(extensionAmount, maxExtension),
|
: Math.min(extensionAmount, maxExtension),
|
||||||
);
|
);
|
||||||
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
|
const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension;
|
||||||
const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
|
const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension;
|
||||||
|
|
||||||
setCurrentTrimStart(0);
|
setCurrentTrimStart(0);
|
||||||
setCurrentStartTime(newStartTime);
|
setCurrentStartTime(newStartTime);
|
||||||
|
|
@ -341,8 +340,8 @@ export function useTimelineElementResize({
|
||||||
0,
|
0,
|
||||||
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
||||||
);
|
);
|
||||||
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
|
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
|
||||||
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
|
const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration);
|
||||||
|
|
||||||
setCurrentTrimStart(newTrimStart);
|
setCurrentTrimStart(newTrimStart);
|
||||||
setCurrentStartTime(newStartTime);
|
setCurrentStartTime(newStartTime);
|
||||||
|
|
@ -369,7 +368,7 @@ export function useTimelineElementResize({
|
||||||
const extensionNeeded = Math.abs(newTrimEnd);
|
const extensionNeeded = Math.abs(newTrimEnd);
|
||||||
const baseDuration =
|
const baseDuration =
|
||||||
resizing.initialDuration + resizing.initialTrimEnd;
|
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);
|
setCurrentDuration(newDuration);
|
||||||
setCurrentTrimEnd(0);
|
setCurrentTrimEnd(0);
|
||||||
|
|
@ -379,7 +378,7 @@ export function useTimelineElementResize({
|
||||||
const unclampedDuration = getDurationForVisibleSourceSpan(
|
const unclampedDuration = getDurationForVisibleSourceSpan(
|
||||||
Math.max(0, sourceDuration - resizing.initialTrimStart),
|
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);
|
setCurrentDuration(newDuration);
|
||||||
setCurrentTrimEnd(0);
|
setCurrentTrimEnd(0);
|
||||||
|
|
@ -398,17 +397,17 @@ export function useTimelineElementResize({
|
||||||
const maxTrimEnd =
|
const maxTrimEnd =
|
||||||
sourceDuration -
|
sourceDuration -
|
||||||
resizing.initialTrimStart -
|
resizing.initialTrimStart -
|
||||||
getVisibleSourceSpanForDuration(minDurationSeconds);
|
getVisibleSourceSpanForDuration(minDuration);
|
||||||
const clampedTrimEnd = Math.min(
|
const clampedTrimEnd = Math.min(
|
||||||
maxTrimEnd,
|
maxTrimEnd,
|
||||||
Math.max(minTrimEndForNeighbor, newTrimEnd),
|
Math.max(minTrimEndForNeighbor, newTrimEnd),
|
||||||
);
|
);
|
||||||
const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
|
const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd;
|
||||||
const visibleSourceSpan = Math.max(
|
const visibleSourceSpan = Math.max(
|
||||||
0,
|
0,
|
||||||
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
|
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
|
||||||
);
|
);
|
||||||
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
|
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
|
||||||
|
|
||||||
setCurrentTrimEnd(finalTrimEnd);
|
setCurrentTrimEnd(finalTrimEnd);
|
||||||
setCurrentDuration(newDuration);
|
setCurrentDuration(newDuration);
|
||||||
|
|
@ -458,7 +457,6 @@ export function useTimelineElementResize({
|
||||||
trimEnd: finalTrimEnd,
|
trimEnd: finalTrimEnd,
|
||||||
startTime: startTimeChanged ? finalStartTime : undefined,
|
startTime: startTimeChanged ? finalStartTime : undefined,
|
||||||
duration: durationChanged ? finalDuration : undefined,
|
duration: durationChanged ? finalDuration : undefined,
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -471,7 +469,6 @@ export function useTimelineElementResize({
|
||||||
element.id,
|
element.id,
|
||||||
onResizeStateChange,
|
onResizeStateChange,
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
rippleEditingEnabled,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ import {
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
import { getKeyframeById } from "@/lib/animation";
|
||||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||||
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
|
import { roundToFrame, snappedSeekTime } from "opencut-wasm";
|
||||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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 { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||||
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
import { BatchCommand } from "@/lib/commands";
|
||||||
|
|
@ -84,10 +86,11 @@ export function useKeyframeDrag({
|
||||||
deltaTime: number;
|
deltaTime: number;
|
||||||
}) => {
|
}) => {
|
||||||
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
||||||
const channel = element.animations?.channels[keyframeRef.propertyPath];
|
const keyframe = getKeyframeById({
|
||||||
const keyframe = channel?.keyframes.find(
|
animations: element.animations,
|
||||||
(keyframe) => keyframe.id === keyframeRef.keyframeId,
|
propertyPath: keyframeRef.propertyPath,
|
||||||
);
|
keyframeId: keyframeRef.keyframeId,
|
||||||
|
});
|
||||||
if (!keyframe) return [];
|
if (!keyframe) return [];
|
||||||
const nextTime = Math.max(
|
const nextTime = Math.max(
|
||||||
0,
|
0,
|
||||||
|
|
@ -142,9 +145,9 @@ export function useKeyframeDrag({
|
||||||
|
|
||||||
if (!dragState.isDragging) return;
|
if (!dragState.isDragging) return;
|
||||||
|
|
||||||
const startX = mouseDownXRef.current ?? clientX;
|
const startX = mouseDownXRef.current ?? clientX;
|
||||||
const rawDelta = (clientX - startX) / pixelsPerSecond;
|
const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND);
|
||||||
const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
|
const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
|
||||||
|
|
||||||
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
|
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
|
||||||
};
|
};
|
||||||
|
|
@ -253,7 +256,7 @@ export function useKeyframeDrag({
|
||||||
if (wasDrag) return;
|
if (wasDrag) return;
|
||||||
|
|
||||||
const duration = editor.timeline.getTotalDuration();
|
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 });
|
editor.playback.seek({ time: seekTime });
|
||||||
|
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
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 { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||||
import {
|
import {
|
||||||
findSnapPoints,
|
findSnapPoints,
|
||||||
|
|
@ -44,7 +44,7 @@ export function useBookmarkDrag({
|
||||||
}: UseBookmarkDragProps) {
|
}: UseBookmarkDragProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
const bookmarks = activeScene?.bookmarks ?? [];
|
const bookmarks = activeScene?.bookmarks ?? [];
|
||||||
const playheadTime = editor.playback.getCurrentTime();
|
const playheadTime = editor.playback.getCurrentTime();
|
||||||
|
|
@ -148,7 +148,7 @@ export function useBookmarkDrag({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
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({
|
const { snappedTime: initialTime } = getSnapResult({
|
||||||
rawTime: frameSnappedTime,
|
rawTime: frameSnappedTime,
|
||||||
excludeBookmarkTime: bookmarkTime,
|
excludeBookmarkTime: bookmarkTime,
|
||||||
|
|
@ -176,9 +176,9 @@ export function useBookmarkDrag({
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
|
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
|
||||||
const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
|
const frameSnappedTime = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime;
|
||||||
const snapResult = getSnapResult({
|
const snapResult = getSnapResult({
|
||||||
rawTime: frameSnappedTime,
|
rawTime: frameSnappedTime,
|
||||||
excludeBookmarkTime: dragState.bookmarkTime,
|
excludeBookmarkTime: dragState.bookmarkTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import { useEditor } from "@/hooks/use-editor";
|
||||||
import { processMediaAssets } from "@/lib/media/processing";
|
import { processMediaAssets } from "@/lib/media/processing";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
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 { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
import { snapTimeToFrame } from "opencut-wasm";
|
import { roundToFrame } from "opencut-wasm";
|
||||||
import {
|
import {
|
||||||
buildTextElement,
|
buildTextElement,
|
||||||
buildGraphicElement,
|
buildGraphicElement,
|
||||||
|
|
@ -17,7 +17,6 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
import { BatchCommand } from "@/lib/commands";
|
||||||
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
||||||
import { getDragData, hasDragData } from "@/lib/drag-data";
|
import { getDragData, hasDragData } from "@/lib/drag-data";
|
||||||
import { isMainTrack } from "@/lib/timeline/placement";
|
|
||||||
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
|
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
|
||||||
import type {
|
import type {
|
||||||
MediaDragData,
|
MediaDragData,
|
||||||
|
|
@ -47,7 +46,7 @@ export function useTimelineDragDrop({
|
||||||
const getSnappedTime = useCallback(
|
const getSnappedTime = useCallback(
|
||||||
({ time }: { time: number }) => {
|
({ time }: { time: number }) => {
|
||||||
const projectFps = editor.project.getActive().settings.fps;
|
const projectFps = editor.project.getActive().settings.fps;
|
||||||
return snapTimeToFrame({ time, fps: projectFps });
|
return roundToFrame({ time, rate: projectFps }) ?? time;
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
@ -83,14 +82,14 @@ export function useTimelineDragDrop({
|
||||||
elementType === "sticker" ||
|
elementType === "sticker" ||
|
||||||
elementType === "effect"
|
elementType === "effect"
|
||||||
) {
|
) {
|
||||||
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
return DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
}
|
}
|
||||||
if (mediaId) {
|
if (mediaId) {
|
||||||
const mediaAssets = editor.media.getAssets();
|
const mediaAssets = editor.media.getAssets();
|
||||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
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],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
@ -149,13 +148,13 @@ export function useTimelineDragDrop({
|
||||||
? (dragData as MediaDragData).targetElementTypes
|
? (dragData as MediaDragData).targetElementTypes
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const target = computeDropTarget({
|
const target = computeDropTarget({
|
||||||
elementType,
|
elementType,
|
||||||
mouseX,
|
mouseX,
|
||||||
mouseY,
|
mouseY,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
playheadTime: currentTime,
|
playheadTime: currentTime,
|
||||||
isExternalDrop: isExternal,
|
isExternalDrop: isExternal,
|
||||||
elementDuration: duration,
|
elementDuration: duration,
|
||||||
|
|
@ -230,7 +229,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
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];
|
const track = tracks[target.trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
editor.timeline.insertElement({
|
editor.timeline.insertElement({
|
||||||
|
|
@ -267,7 +270,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
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];
|
const track = tracks[target.trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
editor.timeline.insertElement({
|
editor.timeline.insertElement({
|
||||||
|
|
@ -305,7 +312,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
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];
|
const track = tracks[target.trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
editor.timeline.insertElement({
|
editor.timeline.insertElement({
|
||||||
|
|
@ -331,7 +342,7 @@ export function useTimelineDragDrop({
|
||||||
dragData.mediaType === "audio" ? "audio" : "video";
|
dragData.mediaType === "audio" ? "audio" : "video";
|
||||||
|
|
||||||
const duration =
|
const duration =
|
||||||
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const element = buildElementFromMedia({
|
const element = buildElementFromMedia({
|
||||||
mediaId: mediaAsset.id,
|
mediaId: mediaAsset.id,
|
||||||
mediaType: mediaAsset.type,
|
mediaType: mediaAsset.type,
|
||||||
|
|
@ -352,7 +363,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
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];
|
const track = tracks[target.trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
editor.timeline.insertElement({
|
editor.timeline.insertElement({
|
||||||
|
|
@ -380,7 +395,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
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");
|
const effectTrack = tracks.find((t) => t.type === "effect");
|
||||||
let trackId: string;
|
let trackId: string;
|
||||||
|
|
||||||
|
|
@ -446,17 +465,15 @@ export function useTimelineDragDrop({
|
||||||
|
|
||||||
const duration =
|
const duration =
|
||||||
createdAsset.duration ??
|
createdAsset.duration ??
|
||||||
DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const currentTracks = editor.timeline.getTracks();
|
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const onlyTrack = currentTracks[0];
|
|
||||||
const reuseMainTrackId =
|
const reuseMainTrackId =
|
||||||
createdAsset.type !== "audio" &&
|
createdAsset.type !== "audio" &&
|
||||||
currentTracks.length === 1 &&
|
sceneTracks.overlay.length === 0 &&
|
||||||
!!onlyTrack &&
|
sceneTracks.audio.length === 0 &&
|
||||||
isMainTrack(onlyTrack) &&
|
sceneTracks.main.elements.length === 0
|
||||||
onlyTrack.elements.length === 0
|
? sceneTracks.main.id
|
||||||
? onlyTrack.id
|
|
||||||
: null;
|
: null;
|
||||||
const dropTarget = reuseMainTrackId
|
const dropTarget = reuseMainTrackId
|
||||||
? null
|
? null
|
||||||
|
|
@ -464,7 +481,7 @@ export function useTimelineDragDrop({
|
||||||
elementType: createdAsset.type,
|
elementType: createdAsset.type,
|
||||||
mouseX,
|
mouseX,
|
||||||
mouseY,
|
mouseY,
|
||||||
tracks: currentTracks,
|
tracks: sceneTracks,
|
||||||
playheadTime: currentTime,
|
playheadTime: currentTime,
|
||||||
isExternalDrop: true,
|
isExternalDrop: true,
|
||||||
elementDuration: duration,
|
elementDuration: duration,
|
||||||
|
|
@ -488,7 +505,11 @@ export function useTimelineDragDrop({
|
||||||
trackId = addTrackCmd.getTrackId();
|
trackId = addTrackCmd.getTrackId();
|
||||||
editor.command.execute({ command: addTrackCmd });
|
editor.command.execute({ command: addTrackCmd });
|
||||||
} else {
|
} 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 { useEffect, useCallback, useRef } from "react";
|
||||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||||
import { useEditor } from "../use-editor";
|
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 { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
|
||||||
import {
|
import {
|
||||||
getCenteredLineLeft,
|
getCenteredLineLeft,
|
||||||
|
timelineTimeToPixels,
|
||||||
timelineTimeToSnappedPixels,
|
timelineTimeToSnappedPixels,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
|
|
@ -66,29 +68,29 @@ export function useTimelinePlayhead({
|
||||||
const rulerRect = ruler.getBoundingClientRect();
|
const rulerRect = ruler.getBoundingClientRect();
|
||||||
const relativeMouseX = event.clientX - rulerRect.left;
|
const relativeMouseX = event.clientX - rulerRect.left;
|
||||||
|
|
||||||
const timelineContentWidth =
|
const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
|
||||||
duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
|
||||||
|
|
||||||
const clampedMouseX = Math.max(
|
const clampedMouseX = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(timelineContentWidth, relativeMouseX),
|
Math.min(timelineContentWidth, relativeMouseX),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawTime = Math.max(
|
const rawTimeSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
duration,
|
duration / TICKS_PER_SECOND,
|
||||||
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
||||||
|
|
||||||
const framesPerSecond = activeProject.settings.fps;
|
const rate = activeProject.settings.fps;
|
||||||
const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
|
const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
|
||||||
|
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||||
const time = (() => {
|
const time = (() => {
|
||||||
if (!shouldSnap) return frameTime;
|
if (!shouldSnap) return frameTime;
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
|
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
|
||||||
const snapPoints = findSnapPoints({
|
const snapPoints = findSnapPoints({
|
||||||
tracks,
|
tracks,
|
||||||
|
|
@ -161,7 +163,7 @@ export function useTimelinePlayhead({
|
||||||
getMouseClientX: () => lastMouseXRef.current,
|
getMouseClientX: () => lastMouseXRef.current,
|
||||||
rulerScrollRef,
|
rulerScrollRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -247,8 +249,10 @@ export function useTimelinePlayhead({
|
||||||
const tracksViewport = tracksScrollRef.current;
|
const tracksViewport = tracksScrollRef.current;
|
||||||
if (!rulerViewport || !tracksViewport) return;
|
if (!rulerViewport || !tracksViewport) return;
|
||||||
|
|
||||||
const playheadPixels =
|
const playheadPixels = timelineTimeToPixels({
|
||||||
time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
|
time,
|
||||||
|
zoomLevel: zoomLevelRef.current,
|
||||||
|
});
|
||||||
const viewportWidth = rulerViewport.clientWidth;
|
const viewportWidth = rulerViewport.clientWidth;
|
||||||
const scrollMinimum = 0;
|
const scrollMinimum = 0;
|
||||||
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
|
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import type { MutableRefObject, RefObject } from "react";
|
import type { MutableRefObject, RefObject } from "react";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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";
|
import { useEditor } from "../use-editor";
|
||||||
|
|
||||||
interface UseTimelineSeekProps {
|
interface UseTimelineSeekProps {
|
||||||
|
|
@ -127,17 +128,18 @@ export function useTimelineSeek({
|
||||||
const mouseX = event.clientX - rect.left;
|
const mouseX = event.clientX - rect.left;
|
||||||
const scrollLeft = scrollContainer.scrollLeft;
|
const scrollLeft = scrollContainer.scrollLeft;
|
||||||
|
|
||||||
const rawTime = Math.max(
|
const rawTimeSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
duration,
|
duration / TICKS_PER_SECOND,
|
||||||
(mouseX + scrollLeft) /
|
(mouseX + scrollLeft) /
|
||||||
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
||||||
|
|
||||||
const projectFps = activeProject?.settings.fps || 30;
|
const rate = activeProject?.settings.fps;
|
||||||
const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
|
const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime;
|
||||||
seek(time);
|
seek(time);
|
||||||
editor.project.setTimelineViewState({
|
editor.project.setTimelineViewState({
|
||||||
viewState: {
|
viewState: {
|
||||||
|
|
@ -154,7 +156,8 @@ export function useTimelineSeek({
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
seek,
|
seek,
|
||||||
editor,
|
editor,
|
||||||
activeProject?.settings.fps,
|
activeProject?.settings.fps.numerator,
|
||||||
|
activeProject?.settings.fps.denominator,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import {
|
||||||
import {
|
import {
|
||||||
TIMELINE_ZOOM_MAX,
|
TIMELINE_ZOOM_MAX,
|
||||||
TIMELINE_ZOOM_MIN,
|
TIMELINE_ZOOM_MIN,
|
||||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
|
||||||
} from "@/lib/timeline/scale";
|
} from "@/lib/timeline/scale";
|
||||||
|
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
|
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||||
|
|
||||||
|
|
@ -186,10 +186,8 @@ export function useTimelineZoom({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||||
const playheadPixelsBefore =
|
const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom });
|
||||||
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
|
const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel });
|
||||||
const playheadPixelsAfter =
|
|
||||||
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
|
||||||
|
|
||||||
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
||||||
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
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
|
* Subscribes to render tracks and returns the live (preview-aware) version of
|
||||||
|
|
@ -18,13 +18,14 @@ export function useElementPreview<T extends TimelineElement>({
|
||||||
fallback: T;
|
fallback: T;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
useEditor((e) => e.timeline.getRenderTracks());
|
useEditor((e) => e.timeline.getPreviewTracks());
|
||||||
|
|
||||||
|
const previewTracks = editor.timeline.getPreviewTracks();
|
||||||
const renderElement =
|
const renderElement =
|
||||||
(editor.timeline
|
(findTrackInSceneTracks({
|
||||||
.getRenderTracks()
|
tracks: previewTracks ?? editor.scenes.getActiveScene().tracks,
|
||||||
.find((t) => t.id === trackId)
|
trackId,
|
||||||
?.elements.find((el) => el.id === elementId) as T | undefined) ??
|
})?.elements.find((element) => element.id === elementId) as T | undefined) ??
|
||||||
fallback;
|
fallback;
|
||||||
|
|
||||||
const previewUpdates = (updates: Partial<TimelineElement>) =>
|
const previewUpdates = (updates: Partial<TimelineElement>) =>
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ export function useMaskHandles({
|
||||||
null,
|
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 currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
const canvasSize = useEditor(
|
const canvasSize = useEditor(
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,114 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { processMediaAssets } from "@/lib/media/processing";
|
import { processMediaAssets } from "@/lib/media/processing";
|
||||||
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
||||||
import { invokeAction } from "@/lib/actions";
|
import { invokeAction } from "@/lib/actions";
|
||||||
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
||||||
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
||||||
import { InsertElementCommand } from "@/lib/commands/timeline";
|
import { InsertElementCommand } from "@/lib/commands/timeline";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
import { BatchCommand } from "@/lib/commands";
|
||||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
|
||||||
import { isTypableDOMElement } from "@/utils/browser";
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
import type { MediaType } from "@/lib/media/types";
|
import type { MediaType } from "@/lib/media/types";
|
||||||
|
|
||||||
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
|
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
|
||||||
|
|
||||||
function isMediaMimeType({ type }: { type: string }): boolean {
|
function isMediaMimeType({ type }: { type: string }): boolean {
|
||||||
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
|
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMediaFilesFromClipboard({
|
function extractMediaFilesFromClipboard({
|
||||||
clipboardData,
|
clipboardData,
|
||||||
}: {
|
}: {
|
||||||
clipboardData: DataTransfer | null;
|
clipboardData: DataTransfer | null;
|
||||||
}): File[] {
|
}): File[] {
|
||||||
if (!clipboardData?.items) return [];
|
if (!clipboardData?.items) return [];
|
||||||
|
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
for (const item of clipboardData.items) {
|
for (const item of clipboardData.items) {
|
||||||
if (item.kind !== "file") continue;
|
if (item.kind !== "file") continue;
|
||||||
if (!isMediaMimeType({ type: item.type })) continue;
|
if (!isMediaMimeType({ type: item.type })) continue;
|
||||||
|
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) files.push(file);
|
if (file) files.push(file);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePasteMedia() {
|
export function usePasteMedia() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
const activeElement = document.activeElement as HTMLElement;
|
const activeElement = document.activeElement as HTMLElement;
|
||||||
|
|
||||||
if (activeElement && isTypableDOMElement({ element: activeElement })) {
|
if (activeElement && isTypableDOMElement({ element: activeElement })) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = extractMediaFilesFromClipboard({
|
const files = extractMediaFilesFromClipboard({
|
||||||
clipboardData: event.clipboardData,
|
clipboardData: event.clipboardData,
|
||||||
});
|
});
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
invokeAction("paste-copied");
|
invokeAction("paste-copied");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await showMediaUploadToast({
|
await showMediaUploadToast({
|
||||||
filesCount: files.length,
|
filesCount: files.length,
|
||||||
promise: async () => {
|
promise: async () => {
|
||||||
const processedAssets = await processMediaAssets({ files });
|
const processedAssets = await processMediaAssets({ files });
|
||||||
const startTime = editor.playback.getCurrentTime();
|
const startTime = editor.playback.getCurrentTime();
|
||||||
|
|
||||||
for (const asset of processedAssets) {
|
for (const asset of processedAssets) {
|
||||||
const addMediaCmd = new AddMediaAssetCommand(
|
const addMediaCmd = new AddMediaAssetCommand(
|
||||||
activeProject.metadata.id,
|
activeProject.metadata.id,
|
||||||
asset,
|
asset,
|
||||||
);
|
);
|
||||||
const assetId = addMediaCmd.getAssetId();
|
const assetId = addMediaCmd.getAssetId();
|
||||||
const duration =
|
const duration =
|
||||||
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const trackType = asset.type === "audio" ? "audio" : "video";
|
const trackType = asset.type === "audio" ? "audio" : "video";
|
||||||
|
|
||||||
const element = buildElementFromMedia({
|
const element = buildElementFromMedia({
|
||||||
mediaId: assetId,
|
mediaId: assetId,
|
||||||
mediaType: asset.type,
|
mediaType: asset.type,
|
||||||
name: asset.name,
|
name: asset.name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
buffer:
|
buffer:
|
||||||
asset.type === "audio"
|
asset.type === "audio"
|
||||||
? new AudioBuffer({ length: 1, sampleRate: 44100 })
|
? new AudioBuffer({ length: 1, sampleRate: 44100 })
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const insertCmd = new InsertElementCommand({
|
const insertCmd = new InsertElementCommand({
|
||||||
element,
|
element,
|
||||||
placement: { mode: "auto", trackType },
|
placement: { mode: "auto", trackType },
|
||||||
});
|
});
|
||||||
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
|
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
|
||||||
editor.command.execute({ command: batchCmd });
|
editor.command.execute({ command: batchCmd });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uploadedCount: processedAssets.length,
|
uploadedCount: processedAssets.length,
|
||||||
assetNames: processedAssets.map((asset) => asset.name),
|
assetNames: processedAssets.map((asset) => asset.name),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to paste media:", error);
|
console.error("Failed to paste media:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("paste", handlePaste);
|
window.addEventListener("paste", handlePaste);
|
||||||
return () => window.removeEventListener("paste", handlePaste);
|
return () => window.removeEventListener("paste", handlePaste);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ export function usePreviewInteraction({
|
||||||
({ clientX, clientY }: React.MouseEvent) => {
|
({ clientX, clientY }: React.MouseEvent) => {
|
||||||
if (editingText || isMaskMode) return;
|
if (editingText || isMaskMode) return;
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const mediaAssets = editor.media.getAssets();
|
const mediaAssets = editor.media.getAssets();
|
||||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||||
|
|
@ -238,7 +238,7 @@ export function usePreviewInteraction({
|
||||||
if (isMaskMode) return;
|
if (isMaskMode) return;
|
||||||
if (button !== 0) return;
|
if (button !== 0) return;
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const mediaAssets = editor.media.getAssets();
|
const mediaAssets = editor.media.getAssets();
|
||||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
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 {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
EffectParamPath,
|
EffectParamPath,
|
||||||
NumberAnimationChannel,
|
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import {
|
import { removeElementKeyframe } from "./keyframes";
|
||||||
getChannel,
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
removeKeyframe,
|
|
||||||
setChannel,
|
|
||||||
upsertKeyframe,
|
|
||||||
} from "./keyframes";
|
|
||||||
import { getChannelValueAtTime } from "./interpolation";
|
|
||||||
|
|
||||||
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
|
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
|
||||||
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
|
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
|
||||||
|
|
@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({
|
||||||
|
|
||||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
||||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
||||||
const channel = getChannel({ animations, propertyPath: path });
|
resolved[paramKey] = animations?.bindings[path]
|
||||||
if (channel && channel.keyframes.length > 0) {
|
? resolveAnimationPathValueAtTime({
|
||||||
resolved[paramKey] = getChannelValueAtTime({
|
animations,
|
||||||
channel,
|
propertyPath: path,
|
||||||
time: localTime,
|
localTime,
|
||||||
fallbackValue: staticValue,
|
fallbackValue: staticValue,
|
||||||
}) as number | string | boolean;
|
})
|
||||||
} else {
|
: staticValue;
|
||||||
resolved[paramKey] = staticValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved;
|
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({
|
export function removeEffectParamKeyframe({
|
||||||
animations,
|
animations,
|
||||||
effectId,
|
effectId,
|
||||||
|
|
@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({
|
||||||
paramKey: string;
|
paramKey: string;
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
}): ElementAnimations | undefined {
|
}): ElementAnimations | undefined {
|
||||||
const path = buildEffectParamPath({ effectId, paramKey });
|
return removeElementKeyframe({
|
||||||
const channel = getChannel({ animations, propertyPath: path });
|
|
||||||
const updatedChannel = removeKeyframe({ channel, keyframeId });
|
|
||||||
return setChannel({
|
|
||||||
animations,
|
animations,
|
||||||
propertyPath: path,
|
propertyPath: buildEffectParamPath({ effectId, paramKey }),
|
||||||
channel: updatedChannel,
|
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 {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
GraphicParamPath,
|
GraphicParamPath,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { ParamValues } from "@/lib/params";
|
||||||
import {
|
import {
|
||||||
getGraphicDefinition,
|
getGraphicDefinition,
|
||||||
resolveGraphicParams,
|
resolveGraphicParams,
|
||||||
} from "@/lib/graphics";
|
} from "@/lib/graphics";
|
||||||
import { getChannel } from "./keyframes";
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
import { getChannelValueAtTime } from "./interpolation";
|
|
||||||
|
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
|
||||||
|
export function buildGraphicParamPath({
|
||||||
export function buildGraphicParamPath({
|
paramKey,
|
||||||
paramKey,
|
}: {
|
||||||
}: {
|
paramKey: string;
|
||||||
paramKey: string;
|
}): GraphicParamPath {
|
||||||
}): GraphicParamPath {
|
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
|
||||||
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
|
}
|
||||||
}
|
|
||||||
|
export function isGraphicParamPath(
|
||||||
export function isGraphicParamPath(
|
propertyPath: string,
|
||||||
propertyPath: string,
|
): propertyPath is GraphicParamPath {
|
||||||
): propertyPath is GraphicParamPath {
|
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
|
||||||
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
|
}
|
||||||
}
|
|
||||||
|
export function parseGraphicParamPath({
|
||||||
export function parseGraphicParamPath({
|
propertyPath,
|
||||||
propertyPath,
|
}: {
|
||||||
}: {
|
propertyPath: string;
|
||||||
propertyPath: string;
|
}): { paramKey: string } | null {
|
||||||
}): { paramKey: string } | null {
|
if (!isGraphicParamPath(propertyPath)) {
|
||||||
if (!isGraphicParamPath(propertyPath)) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
|
||||||
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
|
return paramKey.length > 0 ? { paramKey } : null;
|
||||||
return paramKey.length > 0 ? { paramKey } : null;
|
}
|
||||||
}
|
|
||||||
|
export function resolveGraphicParamsAtTime({
|
||||||
export function resolveGraphicParamsAtTime({
|
element,
|
||||||
element,
|
localTime,
|
||||||
localTime,
|
}: {
|
||||||
}: {
|
element: {
|
||||||
element: {
|
definitionId: string;
|
||||||
definitionId: string;
|
params: ParamValues;
|
||||||
params: ParamValues;
|
animations?: ElementAnimations;
|
||||||
animations?: ElementAnimations;
|
};
|
||||||
};
|
localTime: number;
|
||||||
localTime: number;
|
}): ParamValues {
|
||||||
}): ParamValues {
|
const definition = getGraphicDefinition({
|
||||||
const definition = getGraphicDefinition({
|
definitionId: element.definitionId,
|
||||||
definitionId: element.definitionId,
|
});
|
||||||
});
|
const baseParams = resolveGraphicParams(definition, element.params);
|
||||||
const baseParams = resolveGraphicParams(definition, element.params);
|
const resolved: ParamValues = { ...baseParams };
|
||||||
const resolved: ParamValues = { ...baseParams };
|
|
||||||
|
for (const param of definition.params) {
|
||||||
for (const param of definition.params) {
|
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
if (!element.animations?.bindings[path]) {
|
||||||
const channel = getChannel({
|
continue;
|
||||||
animations: element.animations,
|
}
|
||||||
propertyPath: path,
|
|
||||||
});
|
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||||
if (!channel || channel.keyframes.length === 0) {
|
animations: element.animations,
|
||||||
continue;
|
propertyPath: path,
|
||||||
}
|
localTime: Math.max(0, localTime),
|
||||||
|
fallbackValue: baseParams[param.key] ?? param.default,
|
||||||
resolved[param.key] = getChannelValueAtTime({
|
});
|
||||||
channel,
|
}
|
||||||
time: Math.max(0, localTime),
|
|
||||||
fallbackValue: baseParams[param.key] ?? param.default,
|
return resolved;
|
||||||
}) as number | string | boolean;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,94 @@
|
||||||
export {
|
export {
|
||||||
getChannelValueAtTime,
|
getChannelValueAtTime,
|
||||||
getNumberChannelValueAtTime,
|
getDiscreteChannelValueAtTime,
|
||||||
getVectorChannelValueAtTime,
|
getScalarChannelValueAtTime,
|
||||||
normalizeChannel,
|
getScalarSegmentInterpolation,
|
||||||
} from "./interpolation";
|
normalizeChannel,
|
||||||
|
} from "./interpolation";
|
||||||
export {
|
|
||||||
clampAnimationsToDuration,
|
export {
|
||||||
cloneAnimations,
|
clampAnimationsToDuration,
|
||||||
getChannel,
|
cloneAnimations,
|
||||||
removeElementKeyframe,
|
getChannel,
|
||||||
retimeElementKeyframe,
|
removeElementKeyframe,
|
||||||
setChannel,
|
retimeElementKeyframe,
|
||||||
splitAnimationsAtTime,
|
setBindingComponentChannel,
|
||||||
upsertElementKeyframe,
|
setChannel,
|
||||||
upsertPathKeyframe,
|
splitAnimationsAtTime,
|
||||||
} from "./keyframes";
|
updateScalarKeyframeCurve,
|
||||||
|
upsertElementKeyframe,
|
||||||
export {
|
upsertPathKeyframe,
|
||||||
getElementLocalTime,
|
} from "./keyframes";
|
||||||
resolveColorAtTime,
|
|
||||||
resolveNumberAtTime,
|
export {
|
||||||
resolveOpacityAtTime,
|
getElementLocalTime,
|
||||||
resolveTransformAtTime,
|
resolveAnimationPathValueAtTime,
|
||||||
} from "./resolve";
|
resolveColorAtTime,
|
||||||
|
resolveNumberAtTime,
|
||||||
export {
|
resolveOpacityAtTime,
|
||||||
coerceAnimationValueForProperty,
|
resolveTransformAtTime,
|
||||||
getAnimationPropertyDefinition,
|
} from "./resolve";
|
||||||
getDefaultInterpolationForProperty,
|
|
||||||
getElementBaseValueForProperty,
|
export {
|
||||||
isAnimationPropertyPath,
|
coerceAnimationValueForProperty,
|
||||||
supportsAnimationProperty,
|
getAnimationPropertyDefinition,
|
||||||
type AnimationPropertyDefinition,
|
getDefaultInterpolationForProperty,
|
||||||
type NumericSpec,
|
getElementBaseValueForProperty,
|
||||||
type NumericRange,
|
isAnimationPropertyPath,
|
||||||
withElementBaseValueForProperty,
|
supportsAnimationProperty,
|
||||||
} from "./property-registry";
|
type AnimationPropertyDefinition,
|
||||||
|
type NumericSpec,
|
||||||
export {
|
withElementBaseValueForProperty,
|
||||||
getElementKeyframes,
|
} from "./property-registry";
|
||||||
getKeyframeAtTime,
|
|
||||||
hasKeyframesForPath,
|
export {
|
||||||
} from "./keyframe-query";
|
getElementKeyframes,
|
||||||
|
getKeyframeById,
|
||||||
export {
|
getKeyframeAtTime,
|
||||||
buildGraphicParamPath,
|
hasKeyframesForPath,
|
||||||
isGraphicParamPath,
|
} from "./keyframe-query";
|
||||||
parseGraphicParamPath,
|
|
||||||
resolveGraphicParamsAtTime,
|
export {
|
||||||
} from "./graphic-param-channel";
|
getEditableScalarChannel,
|
||||||
|
getEditableScalarChannels,
|
||||||
export {
|
getScalarKeyframeContext,
|
||||||
isAnimationPath,
|
} from "./graph-channels";
|
||||||
resolveAnimationTarget,
|
|
||||||
getParamValueKind,
|
export {
|
||||||
getParamDefaultInterpolation,
|
getCurveHandlesForNormalizedCubicBezier,
|
||||||
type AnimationPathDescriptor,
|
getNormalizedCubicBezierForScalarSegment,
|
||||||
} from "./target-resolver";
|
} from "./curve-bridge";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getGroupKeyframesAtTime,
|
buildGraphicParamPath,
|
||||||
hasGroupKeyframeAtTime,
|
isGraphicParamPath,
|
||||||
type GroupKeyframeRef,
|
parseGraphicParamPath,
|
||||||
} from "./property-groups";
|
resolveGraphicParamsAtTime,
|
||||||
|
} from "./graphic-param-channel";
|
||||||
export {
|
|
||||||
getVectorChannelForPath,
|
export {
|
||||||
isVectorValue,
|
buildEffectParamPath,
|
||||||
} from "./vector-channel";
|
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 {
|
import type {
|
||||||
AnimationChannel,
|
AnimationChannel,
|
||||||
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
ColorAnimationChannel,
|
|
||||||
DiscreteValue,
|
|
||||||
DiscreteAnimationChannel,
|
DiscreteAnimationChannel,
|
||||||
NumberAnimationChannel,
|
DiscreteValue,
|
||||||
VectorAnimationChannel,
|
ScalarAnimationChannel,
|
||||||
VectorValue,
|
ScalarAnimationKey,
|
||||||
|
ScalarSegmentType,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { isVectorValue } from "./vector-channel";
|
import { clamp } from "@/utils/math";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
import {
|
||||||
|
getBezierPoint,
|
||||||
|
getDefaultLeftHandle,
|
||||||
|
getDefaultRightHandle,
|
||||||
|
solveBezierProgressForTime,
|
||||||
|
} from "./bezier";
|
||||||
|
|
||||||
function byTimeAscending({
|
function byTimeAscending({
|
||||||
leftTime,
|
leftTime,
|
||||||
|
|
@ -30,77 +35,7 @@ function isWithinTimePair({
|
||||||
leftTime: number;
|
leftTime: number;
|
||||||
rightTime: number;
|
rightTime: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return time >= leftTime && time <= rightTime;
|
||||||
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})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lerpNumber({
|
function lerpNumber({
|
||||||
|
|
@ -115,43 +50,99 @@ function lerpNumber({
|
||||||
return leftValue + (rightValue - leftValue) * progress;
|
return leftValue + (rightValue - leftValue) * progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
function interpolateColor({
|
function normalizeRightHandle({
|
||||||
leftColor,
|
handle,
|
||||||
rightColor,
|
leftKey,
|
||||||
progress,
|
rightKey,
|
||||||
}: {
|
}: {
|
||||||
leftColor: string;
|
handle: ScalarAnimationKey["rightHandle"];
|
||||||
rightColor: string;
|
leftKey: ScalarAnimationKey;
|
||||||
progress: number;
|
rightKey: ScalarAnimationKey;
|
||||||
}): string {
|
}) {
|
||||||
const leftParsed = parseHexColor({ color: leftColor });
|
if (!handle) {
|
||||||
const rightParsed = parseHexColor({ color: rightColor });
|
return undefined;
|
||||||
if (!leftParsed || !rightParsed) {
|
|
||||||
return progress >= 1 ? rightColor : leftColor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatRgbaColor({
|
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||||
red: lerpNumber({
|
return {
|
||||||
leftValue: leftParsed.red,
|
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||||
rightValue: rightParsed.red,
|
dv: handle.dv,
|
||||||
progress,
|
};
|
||||||
}),
|
}
|
||||||
green: lerpNumber({
|
|
||||||
leftValue: leftParsed.green,
|
function normalizeLeftHandle({
|
||||||
rightValue: rightParsed.green,
|
handle,
|
||||||
progress,
|
leftKey,
|
||||||
}),
|
rightKey,
|
||||||
blue: lerpNumber({
|
}: {
|
||||||
leftValue: leftParsed.blue,
|
handle: ScalarAnimationKey["leftHandle"];
|
||||||
rightValue: rightParsed.blue,
|
leftKey: ScalarAnimationKey;
|
||||||
progress,
|
rightKey: ScalarAnimationKey;
|
||||||
}),
|
}) {
|
||||||
alpha: lerpNumber({
|
if (!handle) {
|
||||||
leftValue: leftParsed.alpha,
|
return undefined;
|
||||||
rightValue: rightParsed.alpha,
|
}
|
||||||
progress,
|
|
||||||
}),
|
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>({
|
export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||||
|
|
@ -159,9 +150,15 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||||
}: {
|
}: {
|
||||||
channel: TChannel;
|
channel: TChannel;
|
||||||
}): TChannel {
|
}): TChannel {
|
||||||
|
if (channel.kind === "scalar") {
|
||||||
|
return normalizeScalarChannel({
|
||||||
|
channel,
|
||||||
|
}) as TChannel;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...channel,
|
...channel,
|
||||||
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
|
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
|
||||||
byTimeAscending({
|
byTimeAscending({
|
||||||
leftTime: leftKeyframe.time,
|
leftTime: leftKeyframe.time,
|
||||||
rightTime: rightKeyframe.time,
|
rightTime: rightKeyframe.time,
|
||||||
|
|
@ -170,171 +167,154 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||||
} as TChannel;
|
} as TChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateChannelValueAtTime<
|
function extrapolateScalarEdge({
|
||||||
TKeyframe extends { time: number; value: TValue },
|
mode,
|
||||||
TValue,
|
edgeKey,
|
||||||
>({
|
neighborKey,
|
||||||
keyframes,
|
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
|
||||||
getInterpolatedValue,
|
|
||||||
}: {
|
}: {
|
||||||
keyframes: TKeyframe[] | undefined;
|
mode: "hold" | "linear";
|
||||||
|
edgeKey: ScalarAnimationKey;
|
||||||
|
neighborKey: ScalarAnimationKey | undefined;
|
||||||
time: number;
|
time: number;
|
||||||
fallbackValue: TValue;
|
}) {
|
||||||
getInterpolatedValue: ({
|
if (mode === "hold" || !neighborKey) {
|
||||||
leftKeyframe,
|
return edgeKey.value;
|
||||||
rightKeyframe,
|
|
||||||
progress,
|
|
||||||
}: {
|
|
||||||
leftKeyframe: TKeyframe;
|
|
||||||
rightKeyframe: TKeyframe;
|
|
||||||
progress: number;
|
|
||||||
}) => TValue;
|
|
||||||
}): TValue {
|
|
||||||
if (!keyframes || keyframes.length === 0) {
|
|
||||||
return fallbackValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstKeyframe = keyframes[0];
|
const span = neighborKey.time - edgeKey.time;
|
||||||
const lastKeyframe = keyframes[keyframes.length - 1];
|
if (span === 0) {
|
||||||
if (!firstKeyframe || !lastKeyframe) {
|
return edgeKey.value;
|
||||||
return fallbackValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
|
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNumberChannelValueAtTime({
|
export function getScalarSegmentInterpolation({
|
||||||
|
segment,
|
||||||
|
}: {
|
||||||
|
segment: ScalarSegmentType;
|
||||||
|
}): AnimationInterpolation {
|
||||||
|
if (segment === "step") {
|
||||||
|
return "hold";
|
||||||
|
}
|
||||||
|
|
||||||
|
return segment === "bezier" ? "bezier" : "linear";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScalarChannelValueAtTime({
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
fallbackValue,
|
||||||
}: {
|
}: {
|
||||||
channel: NumberAnimationChannel | undefined;
|
channel: ScalarAnimationChannel | undefined;
|
||||||
time: number;
|
time: number;
|
||||||
fallbackValue: number;
|
fallbackValue: number;
|
||||||
}): number {
|
}): number {
|
||||||
return evaluateChannelValueAtTime({
|
if (!channel || channel.keys.length === 0) {
|
||||||
keyframes: channel?.keyframes,
|
return fallbackValue;
|
||||||
time,
|
}
|
||||||
fallbackValue,
|
|
||||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
|
||||||
if (leftKeyframe.interpolation === "hold") {
|
|
||||||
return leftKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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({
|
return lerpNumber({
|
||||||
leftValue: leftKeyframe.value,
|
leftValue: leftKey.value,
|
||||||
rightValue: rightKeyframe.value,
|
rightValue: rightKey.value,
|
||||||
progress,
|
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({
|
export function getDiscreteChannelValueAtTime({
|
||||||
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({
|
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
fallbackValue,
|
||||||
|
|
@ -343,12 +323,21 @@ function getDiscreteValueAtTime({
|
||||||
time: number;
|
time: number;
|
||||||
fallbackValue: DiscreteValue;
|
fallbackValue: DiscreteValue;
|
||||||
}): DiscreteValue {
|
}): DiscreteValue {
|
||||||
return evaluateChannelValueAtTime({
|
if (!channel || channel.keys.length === 0) {
|
||||||
keyframes: channel?.keyframes,
|
return fallbackValue;
|
||||||
time,
|
}
|
||||||
fallbackValue,
|
|
||||||
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
|
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({
|
export function getChannelValueAtTime({
|
||||||
|
|
@ -360,50 +349,25 @@ export function getChannelValueAtTime({
|
||||||
time: number;
|
time: number;
|
||||||
fallbackValue: AnimationValue;
|
fallbackValue: AnimationValue;
|
||||||
}): AnimationValue {
|
}): AnimationValue {
|
||||||
if (!channel || channel.keyframes.length === 0) {
|
if (!channel || channel.keys.length === 0) {
|
||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel.valueKind === "number") {
|
if (channel.kind === "scalar") {
|
||||||
if (typeof fallbackValue !== "number") {
|
return typeof fallbackValue === "number"
|
||||||
return fallbackValue;
|
? getScalarChannelValueAtTime({
|
||||||
}
|
channel,
|
||||||
|
time,
|
||||||
return getNumberChannelValueAtTime({
|
fallbackValue,
|
||||||
channel,
|
})
|
||||||
time,
|
: fallbackValue;
|
||||||
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 (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getDiscreteValueAtTime({
|
return getDiscreteChannelValueAtTime({
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
fallbackValue,
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,294 @@
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
import type {
|
||||||
import type {
|
AnimationBindingInstance,
|
||||||
AnimationPath,
|
AnimationChannel,
|
||||||
ElementAnimations,
|
AnimationPath,
|
||||||
ElementKeyframe,
|
ElementAnimations,
|
||||||
} from "@/lib/animation/types";
|
ElementKeyframe,
|
||||||
import { isAnimationPath } from "./target-resolver";
|
} from "@/lib/animation/types";
|
||||||
|
import {
|
||||||
export function getElementKeyframes({
|
type AnimationComponentValue,
|
||||||
animations,
|
composeAnimationValue,
|
||||||
}: {
|
} from "./binding-values";
|
||||||
animations: ElementAnimations | undefined;
|
import {
|
||||||
}): ElementKeyframe[] {
|
getChannelValueAtTime,
|
||||||
if (!animations) {
|
getScalarSegmentInterpolation,
|
||||||
return [];
|
} from "./interpolation";
|
||||||
}
|
import { isAnimationPath } from "./target-resolver";
|
||||||
|
|
||||||
return Object.entries(animations.channels).flatMap(
|
function getBindingFallbackValue({
|
||||||
([propertyPath, channel]) => {
|
channel,
|
||||||
if (
|
}: {
|
||||||
!channel ||
|
channel: ElementAnimations["channels"][string];
|
||||||
channel.keyframes.length === 0 ||
|
}) {
|
||||||
!isAnimationPath(propertyPath)
|
if (!channel || channel.keys.length === 0) {
|
||||||
) {
|
return channel?.kind === "discrete" ? false : 0;
|
||||||
return [];
|
}
|
||||||
}
|
|
||||||
|
return channel.keys[0].value;
|
||||||
return channel.keyframes.map((keyframe) => ({
|
}
|
||||||
propertyPath,
|
|
||||||
id: keyframe.id,
|
interface BindingKeyframeMatch {
|
||||||
time: keyframe.time,
|
componentIndex: number;
|
||||||
value: keyframe.value,
|
channel: AnimationChannel;
|
||||||
interpolation: keyframe.interpolation,
|
keyframe: AnimationChannel["keys"][number];
|
||||||
}));
|
}
|
||||||
},
|
|
||||||
);
|
function getBindingKeyframeMatches({
|
||||||
}
|
animations,
|
||||||
|
binding,
|
||||||
export function hasKeyframesForPath({
|
}: {
|
||||||
animations,
|
animations: ElementAnimations;
|
||||||
propertyPath,
|
binding: AnimationBindingInstance;
|
||||||
}: {
|
}): BindingKeyframeMatch[] {
|
||||||
animations: ElementAnimations | undefined;
|
return binding.components.flatMap((component, componentIndex) => {
|
||||||
propertyPath: AnimationPath;
|
const channel = animations.channels[component.channelId];
|
||||||
}): boolean {
|
if (!channel || channel.keys.length === 0) {
|
||||||
const channel = animations?.channels[propertyPath];
|
return [];
|
||||||
return Boolean(channel && channel.keyframes.length > 0);
|
}
|
||||||
}
|
|
||||||
|
return channel.keys.map((keyframe) => ({
|
||||||
export function getKeyframeAtTime({
|
componentIndex,
|
||||||
animations,
|
channel,
|
||||||
propertyPath,
|
keyframe,
|
||||||
time,
|
}));
|
||||||
}: {
|
});
|
||||||
animations: ElementAnimations | undefined;
|
}
|
||||||
propertyPath: AnimationPath;
|
|
||||||
time: number;
|
function getUniqueBindingKeyframeMatches({
|
||||||
}): ElementKeyframe | null {
|
animations,
|
||||||
const channel = animations?.channels[propertyPath];
|
binding,
|
||||||
if (!channel || channel.keyframes.length === 0) return null;
|
}: {
|
||||||
const keyframe = channel.keyframes.find(
|
animations: ElementAnimations;
|
||||||
(keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
binding: AnimationBindingInstance;
|
||||||
);
|
}): BindingKeyframeMatch[] {
|
||||||
if (!keyframe) return null;
|
const sortedMatches = getBindingKeyframeMatches({
|
||||||
return {
|
animations,
|
||||||
propertyPath,
|
binding,
|
||||||
id: keyframe.id,
|
}).sort(
|
||||||
time: keyframe.time,
|
(leftMatch, rightMatch) =>
|
||||||
value: keyframe.value,
|
leftMatch.keyframe.time - rightMatch.keyframe.time ||
|
||||||
interpolation: keyframe.interpolation,
|
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 {
|
import type {
|
||||||
AnimationInterpolation,
|
AnimationBindingKind,
|
||||||
AnimationPropertyPath,
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationPropertyPath,
|
||||||
AnimationValueKind,
|
AnimationValue,
|
||||||
DiscreteValue,
|
VectorValue,
|
||||||
VectorValue,
|
} from "@/lib/animation/types";
|
||||||
} from "@/lib/animation/types";
|
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
|
||||||
import { isVectorValue } from "./vector-channel";
|
import type { TimelineElement } from "@/lib/timeline";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
import {
|
||||||
import {
|
CORNER_RADIUS_MAX,
|
||||||
CORNER_RADIUS_MAX,
|
CORNER_RADIUS_MIN,
|
||||||
CORNER_RADIUS_MIN,
|
} from "@/constants/text-constants";
|
||||||
} from "@/constants/text-constants";
|
import {
|
||||||
import {
|
canElementHaveAudio,
|
||||||
canElementHaveAudio,
|
isVisualElement,
|
||||||
isVisualElement,
|
} from "@/lib/timeline/element-utils";
|
||||||
} from "@/lib/timeline/element-utils";
|
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
import { snapToStep } from "@/utils/math";
|
||||||
import { snapToStep } from "@/utils/math";
|
|
||||||
|
export interface NumericSpec {
|
||||||
export interface NumericSpec {
|
min?: number;
|
||||||
min?: number;
|
max?: number;
|
||||||
max?: number;
|
step?: number;
|
||||||
step?: number;
|
}
|
||||||
}
|
|
||||||
|
export interface AnimationPropertyDefinition {
|
||||||
export type NumericRange = NumericSpec;
|
kind: AnimationBindingKind;
|
||||||
|
defaultInterpolation: AnimationInterpolation;
|
||||||
export interface AnimationPropertyDefinition {
|
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||||
valueKind: AnimationValueKind;
|
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||||
defaultInterpolation: AnimationInterpolation;
|
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||||
numericRange?: NumericSpec;
|
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
setValue: ({
|
||||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
element,
|
||||||
setValue: ({
|
value,
|
||||||
element,
|
}: {
|
||||||
value,
|
element: TimelineElement;
|
||||||
}: {
|
value: AnimationValue;
|
||||||
element: TimelineElement;
|
}) => TimelineElement;
|
||||||
value: AnimationValue;
|
}
|
||||||
}) => TimelineElement;
|
|
||||||
}
|
function applyNumericSpec({
|
||||||
|
value,
|
||||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
numericRange,
|
||||||
AnimationPropertyPath,
|
}: {
|
||||||
AnimationPropertyDefinition
|
value: number;
|
||||||
> = {
|
numericRange: NumericSpec | undefined;
|
||||||
"transform.position": {
|
}): number {
|
||||||
valueKind: "vector",
|
if (!numericRange) {
|
||||||
defaultInterpolation: "linear",
|
return value;
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
}
|
||||||
getValue: ({ element }) =>
|
|
||||||
isVisualElement(element) ? element.transform.position : null,
|
const steppedValue =
|
||||||
setValue: ({ element, value }) =>
|
numericRange.step != null
|
||||||
isVisualElement(element)
|
? snapToStep({ value, step: numericRange.step })
|
||||||
? {
|
: value;
|
||||||
...element,
|
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||||
transform: {
|
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||||
...element.transform,
|
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||||
position: value as VectorValue,
|
}
|
||||||
},
|
|
||||||
}
|
function coerceNumberValue({
|
||||||
: element,
|
value,
|
||||||
},
|
numericRange,
|
||||||
"transform.scaleX": {
|
}: {
|
||||||
valueKind: "number",
|
value: AnimationValue;
|
||||||
defaultInterpolation: "linear",
|
numericRange?: NumericSpec;
|
||||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
}): number | null {
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
getValue: ({ element }) =>
|
return null;
|
||||||
isVisualElement(element) ? element.transform.scaleX : null,
|
}
|
||||||
setValue: ({ element, value }) =>
|
|
||||||
isVisualElement(element)
|
return applyNumericSpec({ value, numericRange });
|
||||||
? {
|
}
|
||||||
...element,
|
|
||||||
transform: { ...element.transform, scaleX: value as number },
|
function coerceColorValue({
|
||||||
}
|
value,
|
||||||
: element,
|
}: {
|
||||||
},
|
value: AnimationValue;
|
||||||
"transform.scaleY": {
|
}): string | null {
|
||||||
valueKind: "number",
|
return typeof value === "string" && parseColorToLinearRgba({ color: value })
|
||||||
defaultInterpolation: "linear",
|
? value
|
||||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
: null;
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
}
|
||||||
getValue: ({ element }) =>
|
|
||||||
isVisualElement(element) ? element.transform.scaleY : null,
|
function createNumberPropertyDefinition({
|
||||||
setValue: ({ element, value }) =>
|
numericRange,
|
||||||
isVisualElement(element)
|
supportsElement,
|
||||||
? {
|
getValue,
|
||||||
...element,
|
setValue,
|
||||||
transform: { ...element.transform, scaleY: value as number },
|
}: {
|
||||||
}
|
numericRange?: NumericSpec;
|
||||||
: element,
|
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
||||||
},
|
getValue: AnimationPropertyDefinition["getValue"];
|
||||||
"transform.rotate": {
|
setValue: AnimationPropertyDefinition["setValue"];
|
||||||
valueKind: "number",
|
}): AnimationPropertyDefinition {
|
||||||
defaultInterpolation: "linear",
|
return {
|
||||||
numericRange: { min: -360, max: 360, step: 1 },
|
kind: "number",
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
defaultInterpolation: "linear",
|
||||||
getValue: ({ element }) =>
|
numericRanges: numericRange ? { value: numericRange } : undefined,
|
||||||
isVisualElement(element) ? element.transform.rotate : null,
|
supportsElement,
|
||||||
setValue: ({ element, value }) =>
|
getValue,
|
||||||
isVisualElement(element)
|
coerceValue: ({ value }) =>
|
||||||
? {
|
coerceNumberValue({
|
||||||
...element,
|
value,
|
||||||
transform: { ...element.transform, rotate: value as number },
|
numericRange,
|
||||||
}
|
}),
|
||||||
: element,
|
setValue,
|
||||||
},
|
};
|
||||||
opacity: {
|
}
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
numericRange: { min: 0, max: 1, step: 0.01 },
|
AnimationPropertyPath,
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
AnimationPropertyDefinition
|
||||||
getValue: ({ element }) =>
|
> = {
|
||||||
isVisualElement(element) ? element.opacity : null,
|
"transform.position": {
|
||||||
setValue: ({ element, value }) =>
|
kind: "vector2",
|
||||||
isVisualElement(element)
|
defaultInterpolation: "linear",
|
||||||
? { ...element, opacity: value as number }
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
: element,
|
getValue: ({ element }) =>
|
||||||
},
|
isVisualElement(element) ? element.transform.position : null,
|
||||||
volume: {
|
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
|
||||||
valueKind: "number",
|
setValue: ({ element, value }) =>
|
||||||
defaultInterpolation: "linear",
|
isVisualElement(element)
|
||||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
? {
|
||||||
supportsElement: ({ element }) => canElementHaveAudio(element),
|
...element,
|
||||||
getValue: ({ element }) =>
|
transform: {
|
||||||
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
...element.transform,
|
||||||
setValue: ({ element, value }) =>
|
position: value as VectorValue,
|
||||||
canElementHaveAudio(element)
|
},
|
||||||
? { ...element, volume: value as number }
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
},
|
||||||
color: {
|
"transform.scaleX": createNumberPropertyDefinition({
|
||||||
valueKind: "color",
|
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||||
defaultInterpolation: "linear",
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
getValue: ({ element }) =>
|
||||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
isVisualElement(element) ? element.transform.scaleX : null,
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
element.type === "text"
|
isVisualElement(element)
|
||||||
? { ...element, color: value as string }
|
? {
|
||||||
: element,
|
...element,
|
||||||
},
|
transform: { ...element.transform, scaleX: value as number },
|
||||||
"background.color": {
|
}
|
||||||
valueKind: "color",
|
: element,
|
||||||
defaultInterpolation: "linear",
|
}),
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
"transform.scaleY": createNumberPropertyDefinition({
|
||||||
getValue: ({ element }) =>
|
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||||
element.type === "text" ? element.background.color : null,
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
setValue: ({ element, value }) =>
|
getValue: ({ element }) =>
|
||||||
element.type === "text"
|
isVisualElement(element) ? element.transform.scaleY : null,
|
||||||
? {
|
setValue: ({ element, value }) =>
|
||||||
...element,
|
isVisualElement(element)
|
||||||
background: { ...element.background, color: value as string },
|
? {
|
||||||
}
|
...element,
|
||||||
: element,
|
transform: { ...element.transform, scaleY: value as number },
|
||||||
},
|
}
|
||||||
"background.paddingX": {
|
: element,
|
||||||
valueKind: "number",
|
}),
|
||||||
defaultInterpolation: "linear",
|
"transform.rotate": createNumberPropertyDefinition({
|
||||||
numericRange: { min: 0, step: 1 },
|
numericRange: { min: -360, max: 360, step: 1 },
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
element.type === "text"
|
isVisualElement(element) ? element.transform.rotate : null,
|
||||||
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
setValue: ({ element, value }) =>
|
||||||
: null,
|
isVisualElement(element)
|
||||||
setValue: ({ element, value }) =>
|
? {
|
||||||
element.type === "text"
|
...element,
|
||||||
? {
|
transform: { ...element.transform, rotate: value as number },
|
||||||
...element,
|
}
|
||||||
background: { ...element.background, paddingX: value as number },
|
: element,
|
||||||
}
|
}),
|
||||||
: element,
|
opacity: createNumberPropertyDefinition({
|
||||||
},
|
numericRange: { min: 0, max: 1, step: 0.01 },
|
||||||
"background.paddingY": {
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
valueKind: "number",
|
getValue: ({ element }) =>
|
||||||
defaultInterpolation: "linear",
|
isVisualElement(element) ? element.opacity : null,
|
||||||
numericRange: { min: 0, step: 1 },
|
setValue: ({ element, value }) =>
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
isVisualElement(element)
|
||||||
getValue: ({ element }) =>
|
? { ...element, opacity: value as number }
|
||||||
element.type === "text"
|
: element,
|
||||||
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
}),
|
||||||
: null,
|
volume: createNumberPropertyDefinition({
|
||||||
setValue: ({ element, value }) =>
|
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||||
element.type === "text"
|
supportsElement: ({ element }) => canElementHaveAudio(element),
|
||||||
? {
|
getValue: ({ element }) =>
|
||||||
...element,
|
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
||||||
background: { ...element.background, paddingY: value as number },
|
setValue: ({ element, value }) =>
|
||||||
}
|
canElementHaveAudio(element)
|
||||||
: element,
|
? { ...element, volume: value as number }
|
||||||
},
|
: element,
|
||||||
"background.offsetX": {
|
}),
|
||||||
valueKind: "number",
|
color: {
|
||||||
defaultInterpolation: "linear",
|
kind: "color",
|
||||||
numericRange: { step: 1 },
|
defaultInterpolation: "linear",
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||||
element.type === "text"
|
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||||
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
setValue: ({ element, value }) =>
|
||||||
: null,
|
element.type === "text"
|
||||||
setValue: ({ element, value }) =>
|
? { ...element, color: value as string }
|
||||||
element.type === "text"
|
: element,
|
||||||
? {
|
},
|
||||||
...element,
|
"background.color": {
|
||||||
background: { ...element.background, offsetX: value as number },
|
kind: "color",
|
||||||
}
|
defaultInterpolation: "linear",
|
||||||
: element,
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
},
|
getValue: ({ element }) =>
|
||||||
"background.offsetY": {
|
element.type === "text" ? element.background.color : null,
|
||||||
valueKind: "number",
|
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||||
defaultInterpolation: "linear",
|
setValue: ({ element, value }) =>
|
||||||
numericRange: { step: 1 },
|
element.type === "text"
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
? {
|
||||||
getValue: ({ element }) =>
|
...element,
|
||||||
element.type === "text"
|
background: { ...element.background, color: value as string },
|
||||||
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
}
|
||||||
: null,
|
: element,
|
||||||
setValue: ({ element, value }) =>
|
},
|
||||||
element.type === "text"
|
"background.paddingX": createNumberPropertyDefinition({
|
||||||
? {
|
numericRange: { min: 0, step: 1 },
|
||||||
...element,
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
background: { ...element.background, offsetY: value as number },
|
getValue: ({ element }) =>
|
||||||
}
|
element.type === "text"
|
||||||
: element,
|
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
||||||
},
|
: null,
|
||||||
"background.cornerRadius": {
|
setValue: ({ element, value }) =>
|
||||||
valueKind: "number",
|
element.type === "text"
|
||||||
defaultInterpolation: "linear",
|
? {
|
||||||
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
|
...element,
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
background: { ...element.background, paddingX: value as number },
|
||||||
getValue: ({ element }) =>
|
}
|
||||||
element.type === "text"
|
: element,
|
||||||
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
}),
|
||||||
: null,
|
"background.paddingY": createNumberPropertyDefinition({
|
||||||
setValue: ({ element, value }) =>
|
numericRange: { min: 0, step: 1 },
|
||||||
element.type === "text"
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
? {
|
getValue: ({ element }) =>
|
||||||
...element,
|
element.type === "text"
|
||||||
background: { ...element.background, cornerRadius: value as number },
|
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
||||||
}
|
: null,
|
||||||
: element,
|
setValue: ({ element, value }) =>
|
||||||
},
|
element.type === "text"
|
||||||
};
|
? {
|
||||||
|
...element,
|
||||||
export function isAnimationPropertyPath(
|
background: { ...element.background, paddingY: value as number },
|
||||||
propertyPath: string,
|
}
|
||||||
): propertyPath is AnimationPropertyPath {
|
: element,
|
||||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
}),
|
||||||
}
|
"background.offsetX": createNumberPropertyDefinition({
|
||||||
|
numericRange: { step: 1 },
|
||||||
export function getAnimationPropertyDefinition({
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
propertyPath,
|
getValue: ({ element }) =>
|
||||||
}: {
|
element.type === "text"
|
||||||
propertyPath: AnimationPropertyPath;
|
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
||||||
}): AnimationPropertyDefinition {
|
: null,
|
||||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
setValue: ({ element, value }) =>
|
||||||
}
|
element.type === "text"
|
||||||
|
? {
|
||||||
export function supportsAnimationProperty({
|
...element,
|
||||||
element,
|
background: { ...element.background, offsetX: value as number },
|
||||||
propertyPath,
|
}
|
||||||
}: {
|
: element,
|
||||||
element: TimelineElement;
|
}),
|
||||||
propertyPath: AnimationPropertyPath;
|
"background.offsetY": createNumberPropertyDefinition({
|
||||||
}): boolean {
|
numericRange: { step: 1 },
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
return propertyDefinition.supportsElement({ element });
|
getValue: ({ element }) =>
|
||||||
}
|
element.type === "text"
|
||||||
|
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
||||||
export function getElementBaseValueForProperty({
|
: null,
|
||||||
element,
|
setValue: ({ element, value }) =>
|
||||||
propertyPath,
|
element.type === "text"
|
||||||
}: {
|
? {
|
||||||
element: TimelineElement;
|
...element,
|
||||||
propertyPath: AnimationPropertyPath;
|
background: { ...element.background, offsetY: value as number },
|
||||||
}): AnimationValue | null {
|
}
|
||||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
: element,
|
||||||
if (!definition.supportsElement({ element })) {
|
}),
|
||||||
return null;
|
"background.cornerRadius": createNumberPropertyDefinition({
|
||||||
}
|
numericRange: {
|
||||||
return definition.getValue({ element });
|
min: CORNER_RADIUS_MIN,
|
||||||
}
|
max: CORNER_RADIUS_MAX,
|
||||||
|
step: 1,
|
||||||
export function withElementBaseValueForProperty({
|
},
|
||||||
element,
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
propertyPath,
|
getValue: ({ element }) =>
|
||||||
value,
|
element.type === "text"
|
||||||
}: {
|
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
||||||
element: TimelineElement;
|
: null,
|
||||||
propertyPath: AnimationPropertyPath;
|
setValue: ({ element, value }) =>
|
||||||
value: AnimationValue;
|
element.type === "text"
|
||||||
}): TimelineElement {
|
? {
|
||||||
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
|
...element,
|
||||||
if (coercedValue === null) {
|
background: { ...element.background, cornerRadius: value as number },
|
||||||
return element;
|
}
|
||||||
}
|
: element,
|
||||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
}),
|
||||||
if (!definition.supportsElement({ element })) {
|
};
|
||||||
return element;
|
|
||||||
}
|
export function isAnimationPropertyPath(
|
||||||
return definition.setValue({ element, value: coercedValue });
|
propertyPath: string,
|
||||||
}
|
): propertyPath is AnimationPropertyPath {
|
||||||
|
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
||||||
export function getDefaultInterpolationForProperty({
|
}
|
||||||
propertyPath,
|
|
||||||
}: {
|
export function getAnimationPropertyDefinition({
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath,
|
||||||
}): AnimationInterpolation {
|
}: {
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
propertyPath: AnimationPropertyPath;
|
||||||
return propertyDefinition.defaultInterpolation;
|
}): AnimationPropertyDefinition {
|
||||||
}
|
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||||
|
}
|
||||||
function applyNumericSpec({
|
|
||||||
value,
|
export function supportsAnimationProperty({
|
||||||
numericRange,
|
element,
|
||||||
}: {
|
propertyPath,
|
||||||
value: number;
|
}: {
|
||||||
numericRange: NumericSpec | undefined;
|
element: TimelineElement;
|
||||||
}): number {
|
propertyPath: AnimationPropertyPath;
|
||||||
if (!numericRange) {
|
}): boolean {
|
||||||
return value;
|
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
}
|
return propertyDefinition.supportsElement({ element });
|
||||||
|
}
|
||||||
const steppedValue =
|
|
||||||
numericRange.step != null
|
export function getElementBaseValueForProperty({
|
||||||
? snapToStep({ value, step: numericRange.step })
|
element,
|
||||||
: value;
|
propertyPath,
|
||||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
}: {
|
||||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
element: TimelineElement;
|
||||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
propertyPath: AnimationPropertyPath;
|
||||||
}
|
}): AnimationValue | null {
|
||||||
|
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
export function coerceAnimationValueForProperty({
|
if (!definition.supportsElement({ element })) {
|
||||||
propertyPath,
|
return null;
|
||||||
value,
|
}
|
||||||
}: {
|
return definition.getValue({ element });
|
||||||
propertyPath: AnimationPropertyPath;
|
}
|
||||||
value: AnimationValue;
|
|
||||||
}): AnimationValue | null {
|
export function withElementBaseValueForProperty({
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
element,
|
||||||
|
propertyPath,
|
||||||
if (propertyDefinition.valueKind === "number") {
|
value,
|
||||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
}: {
|
||||||
return null;
|
element: TimelineElement;
|
||||||
}
|
propertyPath: AnimationPropertyPath;
|
||||||
|
value: AnimationValue;
|
||||||
return applyNumericSpec({
|
}): TimelineElement {
|
||||||
value,
|
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
numericRange: propertyDefinition.numericRange,
|
const coercedValue = definition.coerceValue({ value });
|
||||||
});
|
if (coercedValue === null || !definition.supportsElement({ element })) {
|
||||||
}
|
return element;
|
||||||
|
}
|
||||||
if (propertyDefinition.valueKind === "color") {
|
return definition.setValue({ element, value: coercedValue });
|
||||||
return typeof value === "string" ? value : null;
|
}
|
||||||
}
|
|
||||||
|
export function getDefaultInterpolationForProperty({
|
||||||
if (propertyDefinition.valueKind === "vector") {
|
propertyPath,
|
||||||
return isVectorValue(value) ? value : null;
|
}: {
|
||||||
}
|
propertyPath: AnimationPropertyPath;
|
||||||
|
}): AnimationInterpolation {
|
||||||
if (typeof value === "string" || typeof value === "boolean") {
|
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
return value as DiscreteValue;
|
return propertyDefinition.defaultInterpolation;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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 {
|
import type {
|
||||||
AnimationPropertyPath,
|
AnimationColorPropertyPath,
|
||||||
ElementAnimations,
|
AnimationNumericPropertyPath,
|
||||||
} from "@/lib/animation/types";
|
AnimationPath,
|
||||||
import type { Transform } from "@/lib/rendering";
|
AnimationPropertyPath,
|
||||||
import {
|
AnimationValueForPath,
|
||||||
getColorValueAtTime,
|
ElementAnimations,
|
||||||
getNumberChannelValueAtTime,
|
} from "@/lib/animation/types";
|
||||||
getVectorChannelValueAtTime,
|
import type { Transform } from "@/lib/rendering";
|
||||||
} from "./interpolation";
|
import {
|
||||||
import { getColorChannelForPath } from "./color-channel";
|
type AnimationComponentValue,
|
||||||
import { getNumberChannelForPath } from "./number-channel";
|
composeAnimationValue,
|
||||||
import { getVectorChannelForPath } from "./vector-channel";
|
decomposeAnimationValue,
|
||||||
|
} from "./binding-values";
|
||||||
export function getElementLocalTime({
|
import {
|
||||||
timelineTime,
|
getChannelValueAtTime,
|
||||||
elementStartTime,
|
} from "./interpolation";
|
||||||
elementDuration,
|
|
||||||
}: {
|
export function getElementLocalTime({
|
||||||
timelineTime: number;
|
timelineTime,
|
||||||
elementStartTime: number;
|
elementStartTime,
|
||||||
elementDuration: number;
|
elementDuration,
|
||||||
}): number {
|
}: {
|
||||||
const localTime = timelineTime - elementStartTime;
|
timelineTime: number;
|
||||||
if (localTime <= 0) {
|
elementStartTime: number;
|
||||||
return 0;
|
elementDuration: number;
|
||||||
}
|
}): number {
|
||||||
|
const localTime = timelineTime - elementStartTime;
|
||||||
if (localTime >= elementDuration) {
|
if (localTime <= 0) {
|
||||||
return elementDuration;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return localTime;
|
if (localTime >= elementDuration) {
|
||||||
}
|
return elementDuration;
|
||||||
|
}
|
||||||
export function resolveTransformAtTime({
|
|
||||||
baseTransform,
|
return localTime;
|
||||||
animations,
|
}
|
||||||
localTime,
|
|
||||||
}: {
|
export function resolveTransformAtTime({
|
||||||
baseTransform: Transform;
|
baseTransform,
|
||||||
animations: ElementAnimations | undefined;
|
animations,
|
||||||
localTime: number;
|
localTime,
|
||||||
}): Transform {
|
}: {
|
||||||
const safeLocalTime = Math.max(0, localTime);
|
baseTransform: Transform;
|
||||||
return {
|
animations: ElementAnimations | undefined;
|
||||||
position: getVectorChannelValueAtTime({
|
localTime: number;
|
||||||
channel: getVectorChannelForPath({
|
}): Transform {
|
||||||
animations,
|
const safeLocalTime = Math.max(0, localTime);
|
||||||
propertyPath: "transform.position",
|
return {
|
||||||
}),
|
position: resolveAnimationPathValueAtTime({
|
||||||
time: safeLocalTime,
|
animations,
|
||||||
fallbackValue: baseTransform.position,
|
propertyPath: "transform.position",
|
||||||
}),
|
localTime: safeLocalTime,
|
||||||
scaleX: getNumberChannelValueAtTime({
|
fallbackValue: baseTransform.position,
|
||||||
channel: getNumberChannelForPath({
|
}),
|
||||||
animations,
|
scaleX: resolveAnimationPathValueAtTime({
|
||||||
propertyPath: "transform.scaleX",
|
animations,
|
||||||
}),
|
propertyPath: "transform.scaleX",
|
||||||
time: safeLocalTime,
|
localTime: safeLocalTime,
|
||||||
fallbackValue: baseTransform.scaleX,
|
fallbackValue: baseTransform.scaleX,
|
||||||
}),
|
}),
|
||||||
scaleY: getNumberChannelValueAtTime({
|
scaleY: resolveAnimationPathValueAtTime({
|
||||||
channel: getNumberChannelForPath({
|
animations,
|
||||||
animations,
|
propertyPath: "transform.scaleY",
|
||||||
propertyPath: "transform.scaleY",
|
localTime: safeLocalTime,
|
||||||
}),
|
fallbackValue: baseTransform.scaleY,
|
||||||
time: safeLocalTime,
|
}),
|
||||||
fallbackValue: baseTransform.scaleY,
|
rotate: resolveAnimationPathValueAtTime({
|
||||||
}),
|
animations,
|
||||||
rotate: getNumberChannelValueAtTime({
|
propertyPath: "transform.rotate",
|
||||||
channel: getNumberChannelForPath({
|
localTime: safeLocalTime,
|
||||||
animations,
|
fallbackValue: baseTransform.rotate,
|
||||||
propertyPath: "transform.rotate",
|
}),
|
||||||
}),
|
};
|
||||||
time: safeLocalTime,
|
}
|
||||||
fallbackValue: baseTransform.rotate,
|
|
||||||
}),
|
export function resolveOpacityAtTime({
|
||||||
};
|
baseOpacity,
|
||||||
}
|
animations,
|
||||||
|
localTime,
|
||||||
export function resolveOpacityAtTime({
|
}: {
|
||||||
baseOpacity,
|
baseOpacity: number;
|
||||||
animations,
|
animations: ElementAnimations | undefined;
|
||||||
localTime,
|
localTime: number;
|
||||||
}: {
|
}): number {
|
||||||
baseOpacity: number;
|
return resolveAnimationPathValueAtTime({
|
||||||
animations: ElementAnimations | undefined;
|
animations,
|
||||||
localTime: number;
|
propertyPath: "opacity",
|
||||||
}): number {
|
localTime: Math.max(0, localTime),
|
||||||
return getNumberChannelValueAtTime({
|
fallbackValue: baseOpacity,
|
||||||
channel: getNumberChannelForPath({
|
});
|
||||||
animations,
|
}
|
||||||
propertyPath: "opacity",
|
|
||||||
}),
|
export function resolveNumberAtTime({
|
||||||
time: Math.max(0, localTime),
|
baseValue,
|
||||||
fallbackValue: baseOpacity,
|
animations,
|
||||||
});
|
propertyPath,
|
||||||
}
|
localTime,
|
||||||
|
}: {
|
||||||
export function resolveNumberAtTime({
|
baseValue: number;
|
||||||
baseValue,
|
animations: ElementAnimations | undefined;
|
||||||
animations,
|
propertyPath: AnimationNumericPropertyPath;
|
||||||
propertyPath,
|
localTime: number;
|
||||||
localTime,
|
}): number {
|
||||||
}: {
|
return resolveAnimationPathValueAtTime({
|
||||||
baseValue: number;
|
animations,
|
||||||
animations: ElementAnimations | undefined;
|
propertyPath,
|
||||||
propertyPath: AnimationPropertyPath;
|
localTime: Math.max(0, localTime),
|
||||||
localTime: number;
|
fallbackValue: baseValue,
|
||||||
}): number {
|
});
|
||||||
return getNumberChannelValueAtTime({
|
}
|
||||||
channel: getNumberChannelForPath({ animations, propertyPath }),
|
|
||||||
time: Math.max(0, localTime),
|
export function resolveColorAtTime({
|
||||||
fallbackValue: baseValue,
|
baseColor,
|
||||||
});
|
animations,
|
||||||
}
|
propertyPath,
|
||||||
|
localTime,
|
||||||
export function resolveColorAtTime({
|
}: {
|
||||||
baseColor,
|
baseColor: string;
|
||||||
animations,
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath,
|
propertyPath: AnimationColorPropertyPath;
|
||||||
localTime,
|
localTime: number;
|
||||||
}: {
|
}): string {
|
||||||
baseColor: string;
|
return resolveAnimationPathValueAtTime({
|
||||||
animations: ElementAnimations | undefined;
|
animations,
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath,
|
||||||
localTime: number;
|
localTime: Math.max(0, localTime),
|
||||||
}): string {
|
fallbackValue: baseColor,
|
||||||
return getColorValueAtTime({
|
});
|
||||||
channel: getColorChannelForPath({ animations, propertyPath }),
|
}
|
||||||
time: 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 {
|
import type {
|
||||||
AnimationInterpolation,
|
AnimationBindingKind,
|
||||||
AnimationPath,
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationPath,
|
||||||
AnimationValueKind,
|
AnimationValue,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import {
|
import {
|
||||||
parseEffectParamPath,
|
parseEffectParamPath,
|
||||||
isEffectParamPath,
|
isEffectParamPath,
|
||||||
} from "@/lib/animation/effect-param-channel";
|
} from "@/lib/animation/effect-param-channel";
|
||||||
import {
|
import {
|
||||||
isGraphicParamPath,
|
isGraphicParamPath,
|
||||||
parseGraphicParamPath,
|
parseGraphicParamPath,
|
||||||
} from "@/lib/animation/graphic-param-channel";
|
} from "@/lib/animation/graphic-param-channel";
|
||||||
import type { ParamDefinition } from "@/lib/params";
|
import type { ParamDefinition } from "@/lib/params";
|
||||||
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
||||||
import { getGraphicDefinition } from "@/lib/graphics";
|
import { getGraphicDefinition } from "@/lib/graphics";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import type { TimelineElement } from "@/lib/timeline";
|
||||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||||
import { snapToStep } from "@/utils/math";
|
import { snapToStep } from "@/utils/math";
|
||||||
import {
|
import {
|
||||||
coerceAnimationValueForProperty,
|
coerceAnimationValueForProperty,
|
||||||
getAnimationPropertyDefinition,
|
getAnimationPropertyDefinition,
|
||||||
getElementBaseValueForProperty,
|
getElementBaseValueForProperty,
|
||||||
isAnimationPropertyPath,
|
isAnimationPropertyPath,
|
||||||
type NumericSpec,
|
type NumericSpec,
|
||||||
withElementBaseValueForProperty,
|
withElementBaseValueForProperty,
|
||||||
} from "./property-registry";
|
} from "./property-registry";
|
||||||
|
import { parseColorToLinearRgba } from "./binding-values";
|
||||||
export interface AnimationPathDescriptor {
|
|
||||||
valueKind: AnimationValueKind;
|
export interface AnimationPathDescriptor {
|
||||||
defaultInterpolation: AnimationInterpolation;
|
kind: AnimationBindingKind;
|
||||||
numericRange?: NumericSpec;
|
defaultInterpolation: AnimationInterpolation;
|
||||||
getBaseValue(): AnimationValue | null;
|
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||||
setBaseValue(value: AnimationValue): TimelineElement;
|
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||||
}
|
getBaseValue: () => AnimationValue | null;
|
||||||
|
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
|
||||||
export function getParamValueKind({
|
}
|
||||||
param,
|
|
||||||
}: {
|
export function getParamValueKind({
|
||||||
param: ParamDefinition;
|
param,
|
||||||
}): AnimationValueKind {
|
}: {
|
||||||
if (param.type === "number") {
|
param: ParamDefinition;
|
||||||
return "number";
|
}): AnimationBindingKind {
|
||||||
}
|
if (param.type === "number") {
|
||||||
|
return "number";
|
||||||
if (param.type === "color") {
|
}
|
||||||
return "color";
|
|
||||||
}
|
if (param.type === "color") {
|
||||||
|
return "color";
|
||||||
return "discrete";
|
}
|
||||||
}
|
|
||||||
|
return "discrete";
|
||||||
export function getParamDefaultInterpolation({
|
}
|
||||||
param,
|
|
||||||
}: {
|
export function getParamDefaultInterpolation({
|
||||||
param: ParamDefinition;
|
param,
|
||||||
}): AnimationInterpolation {
|
}: {
|
||||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
param: ParamDefinition;
|
||||||
}
|
}): AnimationInterpolation {
|
||||||
|
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||||
function getParamNumericRange({
|
}
|
||||||
param,
|
|
||||||
}: {
|
function getParamNumericRange({
|
||||||
param: ParamDefinition;
|
param,
|
||||||
}): NumericSpec | undefined {
|
}: {
|
||||||
if (param.type !== "number") {
|
param: ParamDefinition;
|
||||||
return undefined;
|
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||||
}
|
if (param.type !== "number") {
|
||||||
|
return undefined;
|
||||||
return {
|
}
|
||||||
min: param.min,
|
|
||||||
max: param.max,
|
return {
|
||||||
step: param.step,
|
value: {
|
||||||
};
|
min: param.min,
|
||||||
}
|
max: param.max,
|
||||||
|
step: param.step,
|
||||||
function coerceParamValue({
|
},
|
||||||
param,
|
};
|
||||||
value,
|
}
|
||||||
}: {
|
|
||||||
param: ParamDefinition;
|
export function coerceAnimationValueForParam({
|
||||||
value: AnimationValue;
|
param,
|
||||||
}): number | string | boolean | null {
|
value,
|
||||||
if (param.type === "number") {
|
}: {
|
||||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
param: ParamDefinition;
|
||||||
return null;
|
value: AnimationValue;
|
||||||
}
|
}): number | string | boolean | null {
|
||||||
|
if (param.type === "number") {
|
||||||
const steppedValue = snapToStep({ value, step: param.step });
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
const minValue = param.min;
|
return null;
|
||||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
}
|
||||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
|
||||||
}
|
const steppedValue = snapToStep({ value, step: param.step });
|
||||||
|
const minValue = param.min;
|
||||||
if (param.type === "color") {
|
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||||
return typeof value === "string" ? value : null;
|
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (param.type === "boolean") {
|
if (param.type === "color") {
|
||||||
return typeof value === "boolean" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value !== "string") {
|
if (param.type === "boolean") {
|
||||||
return null;
|
return typeof value === "boolean" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return param.options.some((option) => option.value === value) ? value : null;
|
if (typeof value !== "string") {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
function buildGraphicParamDescriptor({
|
|
||||||
element,
|
return param.options.some((option) => option.value === value) ? value : null;
|
||||||
paramKey,
|
}
|
||||||
}: {
|
|
||||||
element: TimelineElement;
|
function buildGraphicParamDescriptor({
|
||||||
paramKey: string;
|
element,
|
||||||
}): AnimationPathDescriptor | null {
|
paramKey,
|
||||||
if (element.type !== "graphic") {
|
}: {
|
||||||
return null;
|
element: TimelineElement;
|
||||||
}
|
paramKey: string;
|
||||||
|
}): AnimationPathDescriptor | null {
|
||||||
const definition = getGraphicDefinition({
|
if (element.type !== "graphic") {
|
||||||
definitionId: element.definitionId,
|
return null;
|
||||||
});
|
}
|
||||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
|
||||||
if (!param) {
|
const definition = getGraphicDefinition({
|
||||||
return null;
|
definitionId: element.definitionId,
|
||||||
}
|
});
|
||||||
|
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||||
return {
|
if (!param) {
|
||||||
valueKind: getParamValueKind({ param }),
|
return null;
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
}
|
||||||
numericRange: getParamNumericRange({ param }),
|
|
||||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
return {
|
||||||
setBaseValue: (value) => {
|
kind: getParamValueKind({ param }),
|
||||||
const coercedValue = coerceParamValue({ param, value });
|
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||||
if (coercedValue === null) {
|
numericRanges: getParamNumericRange({ param }),
|
||||||
return element;
|
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||||
}
|
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||||
|
setBaseValue: ({ value }) => {
|
||||||
return {
|
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||||
...element,
|
if (coercedValue === null) {
|
||||||
params: {
|
return element;
|
||||||
...element.params,
|
}
|
||||||
[param.key]: coercedValue,
|
|
||||||
},
|
return {
|
||||||
};
|
...element,
|
||||||
},
|
params: {
|
||||||
};
|
...element.params,
|
||||||
}
|
[param.key]: coercedValue,
|
||||||
|
},
|
||||||
function buildEffectParamDescriptor({
|
};
|
||||||
element,
|
},
|
||||||
effectId,
|
};
|
||||||
paramKey,
|
}
|
||||||
}: {
|
|
||||||
element: TimelineElement;
|
function buildEffectParamDescriptor({
|
||||||
effectId: string;
|
element,
|
||||||
paramKey: string;
|
effectId,
|
||||||
}): AnimationPathDescriptor | null {
|
paramKey,
|
||||||
if (!isVisualElement(element)) {
|
}: {
|
||||||
return null;
|
element: TimelineElement;
|
||||||
}
|
effectId: string;
|
||||||
|
paramKey: string;
|
||||||
const effect = element.effects?.find((candidate) => candidate.id === effectId);
|
}): AnimationPathDescriptor | null {
|
||||||
if (!effect) {
|
if (!isVisualElement(element)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerDefaultEffects();
|
const effect = element.effects?.find((candidate) => candidate.id === effectId);
|
||||||
const definition = effectsRegistry.get(effect.type);
|
if (!effect) {
|
||||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
return null;
|
||||||
if (!param) {
|
}
|
||||||
return null;
|
|
||||||
}
|
registerDefaultEffects();
|
||||||
|
const definition = effectsRegistry.get(effect.type);
|
||||||
return {
|
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||||
valueKind: getParamValueKind({ param }),
|
if (!param) {
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
return null;
|
||||||
numericRange: getParamNumericRange({ param }),
|
}
|
||||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
|
||||||
setBaseValue: (value) => {
|
return {
|
||||||
const coercedValue = coerceParamValue({ param, value });
|
kind: getParamValueKind({ param }),
|
||||||
if (coercedValue === null) {
|
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||||
return element;
|
numericRanges: getParamNumericRange({ param }),
|
||||||
}
|
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||||
|
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||||
return {
|
setBaseValue: ({ value }) => {
|
||||||
...element,
|
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||||
effects:
|
if (coercedValue === null) {
|
||||||
element.effects?.map((candidate) =>
|
return element;
|
||||||
candidate.id !== effectId
|
}
|
||||||
? candidate
|
|
||||||
: {
|
return {
|
||||||
...candidate,
|
...element,
|
||||||
params: {
|
effects:
|
||||||
...candidate.params,
|
element.effects?.map((candidate) =>
|
||||||
[param.key]: coercedValue,
|
candidate.id !== effectId
|
||||||
},
|
? candidate
|
||||||
},
|
: {
|
||||||
) ?? element.effects,
|
...candidate,
|
||||||
};
|
params: {
|
||||||
},
|
...candidate.params,
|
||||||
};
|
[param.key]: coercedValue,
|
||||||
}
|
},
|
||||||
|
},
|
||||||
export function isAnimationPath(
|
) ?? element.effects,
|
||||||
propertyPath: string,
|
};
|
||||||
): propertyPath is AnimationPath {
|
},
|
||||||
return (
|
};
|
||||||
isAnimationPropertyPath(propertyPath) ||
|
}
|
||||||
isGraphicParamPath(propertyPath) ||
|
|
||||||
isEffectParamPath(propertyPath)
|
export function isAnimationPath(
|
||||||
);
|
propertyPath: string,
|
||||||
}
|
): propertyPath is AnimationPath {
|
||||||
|
return (
|
||||||
export function resolveAnimationTarget({
|
isAnimationPropertyPath(propertyPath) ||
|
||||||
element,
|
isGraphicParamPath(propertyPath) ||
|
||||||
path,
|
isEffectParamPath(propertyPath)
|
||||||
}: {
|
);
|
||||||
element: TimelineElement;
|
}
|
||||||
path: AnimationPath;
|
|
||||||
}): AnimationPathDescriptor | null {
|
export function resolveAnimationTarget({
|
||||||
if (isAnimationPropertyPath(path)) {
|
element,
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({
|
path,
|
||||||
propertyPath: path,
|
}: {
|
||||||
});
|
element: TimelineElement;
|
||||||
if (!propertyDefinition.supportsElement({ element })) {
|
path: AnimationPath;
|
||||||
return null;
|
}): AnimationPathDescriptor | null {
|
||||||
}
|
if (isAnimationPropertyPath(path)) {
|
||||||
|
const propertyDefinition = getAnimationPropertyDefinition({
|
||||||
return {
|
propertyPath: path,
|
||||||
valueKind: propertyDefinition.valueKind,
|
});
|
||||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
if (!propertyDefinition.supportsElement({ element })) {
|
||||||
numericRange: propertyDefinition.numericRange,
|
return null;
|
||||||
getBaseValue: () =>
|
}
|
||||||
getElementBaseValueForProperty({
|
|
||||||
element,
|
return {
|
||||||
propertyPath: path,
|
kind: propertyDefinition.kind,
|
||||||
}),
|
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||||
setBaseValue: (value) => {
|
numericRanges: propertyDefinition.numericRanges,
|
||||||
const coercedValue = coerceAnimationValueForProperty({
|
coerceValue: ({ value }) =>
|
||||||
propertyPath: path,
|
coerceAnimationValueForProperty({
|
||||||
value,
|
propertyPath: path,
|
||||||
});
|
value,
|
||||||
if (coercedValue === null) {
|
}),
|
||||||
return element;
|
getBaseValue: () =>
|
||||||
}
|
getElementBaseValueForProperty({
|
||||||
|
element,
|
||||||
return withElementBaseValueForProperty({
|
propertyPath: path,
|
||||||
element,
|
}),
|
||||||
propertyPath: path,
|
setBaseValue: ({ value }) => {
|
||||||
value: coercedValue,
|
const coercedValue = propertyDefinition.coerceValue({ value });
|
||||||
});
|
if (coercedValue === null) {
|
||||||
},
|
return element;
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
return withElementBaseValueForProperty({
|
||||||
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
|
element,
|
||||||
if (graphicParamTarget) {
|
propertyPath: path,
|
||||||
return buildGraphicParamDescriptor({
|
value: coercedValue,
|
||||||
element,
|
});
|
||||||
paramKey: graphicParamTarget.paramKey,
|
},
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
|
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
|
||||||
if (effectParamTarget) {
|
if (graphicParamTarget) {
|
||||||
return buildEffectParamDescriptor({
|
return buildGraphicParamDescriptor({
|
||||||
element,
|
element,
|
||||||
effectId: effectParamTarget.effectId,
|
paramKey: graphicParamTarget.paramKey,
|
||||||
paramKey: effectParamTarget.paramKey,
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
|
||||||
return null;
|
if (effectParamTarget) {
|
||||||
}
|
return buildEffectParamDescriptor({
|
||||||
|
element,
|
||||||
|
effectId: effectParamTarget.effectId,
|
||||||
|
paramKey: effectParamTarget.paramKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,119 +1,220 @@
|
||||||
export const ANIMATION_PROPERTY_PATHS = [
|
import type { ParamValues } from "@/lib/params";
|
||||||
"transform.position",
|
|
||||||
"transform.scaleX",
|
export const ANIMATION_PROPERTY_PATHS = [
|
||||||
"transform.scaleY",
|
"transform.position",
|
||||||
"transform.rotate",
|
"transform.scaleX",
|
||||||
"opacity",
|
"transform.scaleY",
|
||||||
"volume",
|
"transform.rotate",
|
||||||
"color",
|
"opacity",
|
||||||
"background.color",
|
"volume",
|
||||||
"background.paddingX",
|
"color",
|
||||||
"background.paddingY",
|
"background.color",
|
||||||
"background.offsetX",
|
"background.paddingX",
|
||||||
"background.offsetY",
|
"background.paddingY",
|
||||||
"background.cornerRadius",
|
"background.offsetX",
|
||||||
] as const;
|
"background.offsetY",
|
||||||
|
"background.cornerRadius",
|
||||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
] as const;
|
||||||
export type GraphicParamPath = `params.${string}`;
|
|
||||||
export type EffectParamPath = `effects.${string}.params.${string}`;
|
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||||
export type AnimationPath =
|
export type GraphicParamPath = `params.${string}`;
|
||||||
| AnimationPropertyPath
|
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||||
| GraphicParamPath
|
export type AnimationPath =
|
||||||
| EffectParamPath;
|
| AnimationPropertyPath
|
||||||
|
| GraphicParamPath
|
||||||
export const ANIMATION_PROPERTY_GROUPS = {
|
| EffectParamPath;
|
||||||
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
|
||||||
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
export const ANIMATION_PROPERTY_GROUPS = {
|
||||||
|
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||||
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
||||||
|
|
||||||
export type VectorValue = { x: number; y: number };
|
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||||
|
|
||||||
export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
|
export type VectorValue = { x: number; y: number };
|
||||||
export type DiscreteValue = boolean | string;
|
export type DiscreteValue = boolean | string;
|
||||||
export type AnimationValue = number | string | boolean | VectorValue;
|
export type AnimationValue = number | string | boolean | VectorValue;
|
||||||
|
export interface AnimationPropertyValueMap {
|
||||||
export type ContinuousKeyframeInterpolation = "linear" | "hold";
|
"transform.position": VectorValue;
|
||||||
export type DiscreteKeyframeInterpolation = "hold";
|
"transform.scaleX": number;
|
||||||
export type AnimationInterpolation =
|
"transform.scaleY": number;
|
||||||
| ContinuousKeyframeInterpolation
|
"transform.rotate": number;
|
||||||
| DiscreteKeyframeInterpolation;
|
opacity: number;
|
||||||
|
volume: number;
|
||||||
interface BaseAnimationKeyframe<
|
color: string;
|
||||||
TValue extends AnimationValue,
|
"background.color": string;
|
||||||
TInterpolation extends AnimationInterpolation,
|
"background.paddingX": number;
|
||||||
> {
|
"background.paddingY": number;
|
||||||
id: string;
|
"background.offsetX": number;
|
||||||
time: number; // relative to element start time
|
"background.offsetY": number;
|
||||||
value: TValue;
|
"background.cornerRadius": number;
|
||||||
interpolation: TInterpolation;
|
}
|
||||||
}
|
export type DynamicAnimationPathValue = ParamValues[string];
|
||||||
|
export type AnimationValueForPath<TPath extends AnimationPath> =
|
||||||
export interface NumberKeyframe
|
TPath extends AnimationPropertyPath
|
||||||
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
|
? AnimationPropertyValueMap[TPath]
|
||||||
|
: TPath extends GraphicParamPath | EffectParamPath
|
||||||
export interface ColorKeyframe
|
? DynamicAnimationPathValue
|
||||||
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
|
: never;
|
||||||
|
export type AnimationNumericPropertyPath = {
|
||||||
export interface DiscreteKeyframe
|
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
|
||||||
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
|
}[AnimationPropertyPath];
|
||||||
|
export type AnimationColorPropertyPath = {
|
||||||
export interface VectorKeyframe
|
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
|
||||||
extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
|
}[AnimationPropertyPath];
|
||||||
|
|
||||||
export type AnimationKeyframe =
|
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||||
| NumberKeyframe
|
export type DiscreteKeyframeInterpolation = "hold";
|
||||||
| ColorKeyframe
|
export type AnimationInterpolation =
|
||||||
| DiscreteKeyframe
|
| ContinuousKeyframeInterpolation
|
||||||
| VectorKeyframe;
|
| DiscreteKeyframeInterpolation;
|
||||||
|
|
||||||
interface BaseAnimationChannel<
|
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
|
||||||
TValueKind extends AnimationValueKind,
|
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
|
||||||
TKeyframe extends AnimationKeyframe,
|
export type ScalarSegmentType = "step" | "linear" | "bezier";
|
||||||
> {
|
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
|
||||||
valueKind: TValueKind;
|
export type ChannelExtrapolationMode = "hold" | "linear";
|
||||||
keyframes: TKeyframe[];
|
|
||||||
}
|
export interface CurveHandle {
|
||||||
|
dt: number;
|
||||||
export interface NumberAnimationChannel
|
dv: number;
|
||||||
extends BaseAnimationChannel<"number", NumberKeyframe> {}
|
}
|
||||||
|
|
||||||
export interface ColorAnimationChannel
|
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
|
||||||
extends BaseAnimationChannel<"color", ColorKeyframe> {}
|
id: string;
|
||||||
|
time: number; // relative to element start time
|
||||||
export interface DiscreteAnimationChannel
|
value: TValue;
|
||||||
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
|
}
|
||||||
|
|
||||||
export interface VectorAnimationChannel
|
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
|
||||||
extends BaseAnimationChannel<"vector", VectorKeyframe> {}
|
leftHandle?: CurveHandle;
|
||||||
|
rightHandle?: CurveHandle;
|
||||||
export type AnimationChannel =
|
segmentToNext: ScalarSegmentType;
|
||||||
| NumberAnimationChannel
|
tangentMode: TangentMode;
|
||||||
| ColorAnimationChannel
|
}
|
||||||
| DiscreteAnimationChannel
|
|
||||||
| VectorAnimationChannel;
|
export interface DiscreteAnimationKey
|
||||||
|
extends BaseAnimationKeyframe<DiscreteValue> {}
|
||||||
export type ElementAnimationChannelMap = Record<
|
|
||||||
string,
|
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
|
||||||
AnimationChannel | undefined
|
|
||||||
>;
|
export interface ScalarAnimationChannel {
|
||||||
|
kind: "scalar";
|
||||||
export interface ElementAnimations {
|
keys: ScalarAnimationKey[];
|
||||||
channels: ElementAnimationChannelMap;
|
extrapolation?: {
|
||||||
}
|
before: ChannelExtrapolationMode;
|
||||||
|
after: ChannelExtrapolationMode;
|
||||||
export interface ElementKeyframe {
|
};
|
||||||
propertyPath: AnimationPath;
|
}
|
||||||
id: string;
|
|
||||||
time: number;
|
export interface DiscreteAnimationChannel {
|
||||||
value: AnimationValue;
|
kind: "discrete";
|
||||||
interpolation: AnimationInterpolation;
|
keys: DiscreteAnimationKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectedKeyframeRef {
|
export type AnimationChannel =
|
||||||
trackId: string;
|
| ScalarAnimationChannel
|
||||||
elementId: string;
|
| DiscreteAnimationChannel;
|
||||||
propertyPath: AnimationPath;
|
|
||||||
keyframeId: string;
|
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 { EditorCore } from "@/core";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
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";
|
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
||||||
|
|
||||||
export class AddMediaAssetCommand extends Command {
|
export class AddMediaAssetCommand extends Command {
|
||||||
private assetId: string;
|
private assetId: string;
|
||||||
private savedAssets: MediaAsset[] | null = null;
|
private savedAssets: MediaAsset[] | null = null;
|
||||||
private createdAsset: MediaAsset | null = null;
|
private createdAsset: MediaAsset | null = null;
|
||||||
private previousProjectFps: number | null = null;
|
private previousProjectFps: FrameRate | null = null;
|
||||||
private appliedProjectFps: number | null = null;
|
private appliedProjectFps: FrameRate | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private projectId: string,
|
private projectId: string,
|
||||||
|
|
@ -23,7 +24,7 @@ export class AddMediaAssetCommand extends Command {
|
||||||
this.assetId = generateUUID();
|
this.assetId = generateUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedAssets = [...editor.media.getAssets()];
|
this.savedAssets = [...editor.media.getAssets()];
|
||||||
|
|
||||||
|
|
@ -53,11 +54,15 @@ export class AddMediaAssetCommand extends Command {
|
||||||
assets: currentAssets.filter((asset) => asset.id !== this.assetId),
|
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 }> =
|
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) {
|
for (const element of track.elements) {
|
||||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
||||||
orphanedElements.push({
|
orphanedElements.push({
|
||||||
|
|
@ -80,6 +85,8 @@ export class AddMediaAssetCommand extends Command {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
|
|
@ -110,14 +117,15 @@ export class AddMediaAssetCommand extends Command {
|
||||||
|
|
||||||
const activeProject = editor.project.getActiveOrNull();
|
const activeProject = editor.project.getActiveOrNull();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
if (activeProject.settings.fps !== this.appliedProjectFps) return;
|
if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
|
||||||
|
|
||||||
const highestRemainingVideoFps = getHighestImportedVideoFps({
|
const highestRemainingVideoFps = getHighestImportedVideoFps({
|
||||||
mediaAssets: editor.media.getAssets(),
|
mediaAssets: editor.media.getAssets(),
|
||||||
});
|
});
|
||||||
|
const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator;
|
||||||
if (
|
if (
|
||||||
highestRemainingVideoFps !== null &&
|
highestRemainingVideoFps !== null &&
|
||||||
highestRemainingVideoFps >= this.appliedProjectFps
|
highestRemainingVideoFps >= appliedFpsFloat
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,103 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
import { videoCache } from "@/services/video-cache/service";
|
import { videoCache } from "@/services/video-cache/service";
|
||||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
|
|
||||||
export class RemoveMediaAssetCommand extends Command {
|
export class RemoveMediaAssetCommand extends Command {
|
||||||
private savedAssets: MediaAsset[] | null = null;
|
private savedAssets: MediaAsset[] | null = null;
|
||||||
private savedTracks: TimelineTrack[] | null = null;
|
private savedTracks: SceneTracks | null = null;
|
||||||
private removedAsset: MediaAsset | null = null;
|
private removedAsset: MediaAsset | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private projectId: string,
|
private projectId: string,
|
||||||
private assetId: string,
|
private assetId: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const assets = editor.media.getAssets();
|
const assets = editor.media.getAssets();
|
||||||
|
|
||||||
this.savedAssets = [...assets];
|
this.savedAssets = [...assets];
|
||||||
this.savedTracks = editor.timeline.getTracks();
|
this.savedTracks = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
this.removedAsset =
|
this.removedAsset =
|
||||||
assets.find((media) => media.id === this.assetId) ?? null;
|
assets.find((media) => media.id === this.assetId) ?? null;
|
||||||
|
|
||||||
if (!this.removedAsset) {
|
if (!this.removedAsset) {
|
||||||
console.error("Media asset not found:", this.assetId);
|
console.error("Media asset not found:", this.assetId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.removedAsset.url) {
|
if (this.removedAsset.url) {
|
||||||
URL.revokeObjectURL(this.removedAsset.url);
|
URL.revokeObjectURL(this.removedAsset.url);
|
||||||
}
|
}
|
||||||
if (this.removedAsset.thumbnailUrl) {
|
if (this.removedAsset.thumbnailUrl) {
|
||||||
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
|
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
videoCache.clearVideo({ mediaId: this.assetId });
|
videoCache.clearVideo({ mediaId: this.assetId });
|
||||||
|
|
||||||
editor.media.setAssets({
|
editor.media.setAssets({
|
||||||
assets: assets.filter((media) => media.id !== this.assetId),
|
assets: assets.filter((media) => media.id !== this.assetId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
||||||
|
|
||||||
for (const track of this.savedTracks) {
|
for (const track of [
|
||||||
for (const element of track.elements) {
|
...this.savedTracks.overlay,
|
||||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
this.savedTracks.main,
|
||||||
elementsToRemove.push({ trackId: track.id, elementId: element.id });
|
...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
|
if (elementsToRemove.length > 0) {
|
||||||
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
|
editor.timeline.deleteElements({ elements: elementsToRemove });
|
||||||
.catch((error) => {
|
}
|
||||||
console.error("Failed to delete media item:", error);
|
|
||||||
});
|
storageService
|
||||||
}
|
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
|
||||||
|
.catch((error) => {
|
||||||
undo(): void {
|
console.error("Failed to delete media item:", error);
|
||||||
const editor = EditorCore.getInstance();
|
});
|
||||||
|
}
|
||||||
if (this.savedAssets && this.removedAsset) {
|
|
||||||
const restoredAsset: MediaAsset = {
|
undo(): void {
|
||||||
...this.removedAsset,
|
const editor = EditorCore.getInstance();
|
||||||
url: URL.createObjectURL(this.removedAsset.file),
|
|
||||||
};
|
if (this.savedAssets && this.removedAsset) {
|
||||||
|
const restoredAsset: MediaAsset = {
|
||||||
editor.media.setAssets({
|
...this.removedAsset,
|
||||||
assets: this.savedAssets.map((a) =>
|
url: URL.createObjectURL(this.removedAsset.file),
|
||||||
a.id === this.assetId ? restoredAsset : a,
|
};
|
||||||
),
|
|
||||||
});
|
editor.media.setAssets({
|
||||||
|
assets: this.savedAssets.map((a) =>
|
||||||
storageService
|
a.id === this.assetId ? restoredAsset : a,
|
||||||
.saveMediaAsset({
|
),
|
||||||
projectId: this.projectId,
|
});
|
||||||
mediaAsset: restoredAsset,
|
|
||||||
})
|
storageService
|
||||||
.catch((error) => {
|
.saveMediaAsset({
|
||||||
console.error("Failed to restore media item on undo:", error);
|
projectId: this.projectId,
|
||||||
});
|
mediaAsset: restoredAsset,
|
||||||
}
|
})
|
||||||
|
.catch((error) => {
|
||||||
if (this.savedTracks) {
|
console.error("Failed to restore media item on undo:", error);
|
||||||
editor.timeline.updateTracks(this.savedTracks);
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
if (this.savedTracks) {
|
||||||
|
editor.timeline.updateTracks(this.savedTracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
export class PreviewTracker<T> {
|
export class PreviewTracker<T> {
|
||||||
private snapshot: T | null = null;
|
private snapshot: T | null = null;
|
||||||
|
|
||||||
begin({ state }: { state: T }): void {
|
begin({ state }: { state: T }): void {
|
||||||
if (this.snapshot === null) {
|
if (this.snapshot === null) {
|
||||||
this.snapshot = structuredClone(state);
|
this.snapshot = structuredClone(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
return this.snapshot !== null;
|
return this.snapshot !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot(): T | null {
|
getSnapshot(): T | null {
|
||||||
return this.snapshot;
|
return this.snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
end(): T | null {
|
end(): T | null {
|
||||||
const snapshot = this.snapshot;
|
const snapshot = this.snapshot;
|
||||||
this.snapshot = null;
|
this.snapshot = null;
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TProject, TProjectSettings } from "@/lib/project/types";
|
import type { TProject, TProjectSettings } from "@/lib/project/types";
|
||||||
|
|
||||||
export class UpdateProjectSettingsCommand extends Command {
|
export class UpdateProjectSettingsCommand extends Command {
|
||||||
private savedSettings: TProjectSettings | null = null;
|
private savedSettings: TProjectSettings | null = null;
|
||||||
private savedUpdatedAt: Date | null = null;
|
private savedUpdatedAt: Date | null = null;
|
||||||
|
|
||||||
constructor(private updates: Partial<TProjectSettings>) {
|
constructor(private updates: Partial<TProjectSettings>) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
|
|
||||||
this.savedSettings = activeProject.settings;
|
this.savedSettings = activeProject.settings;
|
||||||
this.savedUpdatedAt = activeProject.metadata.updatedAt;
|
this.savedUpdatedAt = activeProject.metadata.updatedAt;
|
||||||
|
|
||||||
const updatedProject: TProject = {
|
const updatedProject: TProject = {
|
||||||
...activeProject,
|
...activeProject,
|
||||||
settings: { ...activeProject.settings, ...this.updates },
|
settings: { ...activeProject.settings, ...this.updates },
|
||||||
metadata: { ...activeProject.metadata, updatedAt: new Date() },
|
metadata: { ...activeProject.metadata, updatedAt: new Date() },
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.project.setActiveProject({ project: updatedProject });
|
editor.project.setActiveProject({ project: updatedProject });
|
||||||
editor.save.markDirty();
|
editor.save.markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (!this.savedSettings || !this.savedUpdatedAt) return;
|
if (!this.savedSettings || !this.savedUpdatedAt) return;
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
|
|
||||||
const updatedProject: TProject = {
|
const updatedProject: TProject = {
|
||||||
...activeProject,
|
...activeProject,
|
||||||
settings: this.savedSettings,
|
settings: this.savedSettings,
|
||||||
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
|
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.project.setActiveProject({ project: updatedProject });
|
editor.project.setActiveProject({ project: updatedProject });
|
||||||
editor.save.markDirty();
|
editor.save.markDirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,41 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { buildDefaultScene } from "@/lib/scenes";
|
import { buildDefaultScene } from "@/lib/scenes";
|
||||||
|
|
||||||
export class CreateSceneCommand extends Command {
|
export class CreateSceneCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private createdScene: TScene | null = null;
|
private createdScene: TScene | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private name: string,
|
private name: string,
|
||||||
private isMain: boolean = false,
|
private isMain: boolean = false,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedScenes = [...editor.scenes.getScenes()];
|
this.savedScenes = [...editor.scenes.getScenes()];
|
||||||
|
|
||||||
this.createdScene = buildDefaultScene({
|
this.createdScene = buildDefaultScene({
|
||||||
name: this.name,
|
name: this.name,
|
||||||
isMain: this.isMain,
|
isMain: this.isMain,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedScenes = [...this.savedScenes, this.createdScene];
|
const updatedScenes = [...this.savedScenes, this.createdScene];
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
return undefined;
|
||||||
|
}
|
||||||
undo(): void {
|
|
||||||
if (this.savedScenes) {
|
undo(): void {
|
||||||
const editor = EditorCore.getInstance();
|
if (this.savedScenes) {
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
const editor = EditorCore.getInstance();
|
||||||
}
|
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
getSceneId(): string {
|
|
||||||
return this.createdScene?.id ?? "";
|
getSceneId(): string {
|
||||||
}
|
return this.createdScene?.id ?? "";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,59 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
|
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
|
||||||
|
|
||||||
export class DeleteSceneCommand extends Command {
|
export class DeleteSceneCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private savedActiveSceneId: string | null = null;
|
private savedActiveSceneId: string | null = null;
|
||||||
private deletedScene: TScene | null = null;
|
private deletedScene: TScene | null = null;
|
||||||
|
|
||||||
constructor(private sceneId: string) {
|
constructor(private sceneId: string) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
|
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
this.savedActiveSceneId = activeScene?.id ?? null;
|
this.savedActiveSceneId = activeScene?.id ?? null;
|
||||||
|
|
||||||
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
|
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
|
||||||
|
|
||||||
if (!this.deletedScene) {
|
if (!this.deletedScene) {
|
||||||
console.error("Scene not found:", this.sceneId);
|
console.error("Scene not found:", this.sceneId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
|
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
|
||||||
if (!canDelete) {
|
if (!canDelete) {
|
||||||
console.error("Cannot delete scene:", reason);
|
console.error("Cannot delete scene:", reason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
|
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
|
||||||
|
|
||||||
const newActiveScene = getFallbackSceneAfterDelete({
|
const newActiveScene = getFallbackSceneAfterDelete({
|
||||||
scenes: updatedScenes,
|
scenes: updatedScenes,
|
||||||
deletedSceneId: this.sceneId,
|
deletedSceneId: this.sceneId,
|
||||||
currentSceneId: activeScene?.id ?? null,
|
currentSceneId: activeScene?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({
|
editor.scenes.setScenes({
|
||||||
scenes: updatedScenes,
|
scenes: updatedScenes,
|
||||||
activeSceneId: newActiveScene?.id,
|
activeSceneId: newActiveScene?.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes && this.deletedScene) {
|
if (this.savedScenes && this.deletedScene) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({
|
editor.scenes.setScenes({
|
||||||
scenes: this.savedScenes,
|
scenes: this.savedScenes,
|
||||||
activeSceneId: this.savedActiveSceneId ?? undefined,
|
activeSceneId: this.savedActiveSceneId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export { CreateSceneCommand } from "./create-scene";
|
export { CreateSceneCommand } from "./create-scene";
|
||||||
export { DeleteSceneCommand } from "./delete-scene";
|
export { DeleteSceneCommand } from "./delete-scene";
|
||||||
export { RenameSceneCommand } from "./rename-scene";
|
export { RenameSceneCommand } from "./rename-scene";
|
||||||
export { ToggleBookmarkCommand } from "./toggle-bookmark";
|
export { ToggleBookmarkCommand } from "./toggle-bookmark";
|
||||||
export { RemoveBookmarkCommand } from "./remove-bookmark";
|
export { RemoveBookmarkCommand } from "./remove-bookmark";
|
||||||
export { UpdateBookmarkCommand } from "./update-bookmark";
|
export { UpdateBookmarkCommand } from "./update-bookmark";
|
||||||
export { MoveBookmarkCommand } from "./move-bookmark";
|
export { MoveBookmarkCommand } from "./move-bookmark";
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,59 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { updateSceneInArray } from "@/lib/scenes";
|
import { updateSceneInArray } from "@/lib/scenes";
|
||||||
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
|
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
|
||||||
|
|
||||||
export class MoveBookmarkCommand extends Command {
|
export class MoveBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fromTime: number,
|
private fromTime: number,
|
||||||
private toTime: number,
|
private toTime: number,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
|
|
||||||
if (!activeScene || !activeProject) {
|
if (!activeScene || !activeProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
|
|
||||||
const fromFrameTime = getFrameTime({
|
const fromFrameTime = getFrameTime({
|
||||||
time: this.fromTime,
|
time: this.fromTime,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
const toFrameTime = getFrameTime({
|
const toFrameTime = getFrameTime({
|
||||||
time: this.toTime,
|
time: this.toTime,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedBookmarks = moveBookmarkInArray({
|
const updatedBookmarks = moveBookmarkInArray({
|
||||||
bookmarks: activeScene.bookmarks,
|
bookmarks: activeScene.bookmarks,
|
||||||
fromTime: fromFrameTime,
|
fromTime: fromFrameTime,
|
||||||
toTime: toFrameTime,
|
toTime: toFrameTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedScenes = updateSceneInArray({
|
const updatedScenes = updateSceneInArray({
|
||||||
scenes,
|
scenes,
|
||||||
sceneId: activeScene.id,
|
sceneId: activeScene.id,
|
||||||
updates: { bookmarks: updatedBookmarks },
|
updates: { bookmarks: updatedBookmarks },
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes) {
|
if (this.savedScenes) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,59 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { updateSceneInArray } from "@/lib/scenes";
|
import { updateSceneInArray } from "@/lib/scenes";
|
||||||
import {
|
import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
removeBookmarkFromArray,
|
removeBookmarkFromArray,
|
||||||
} from "@/lib/timeline/bookmarks";
|
} from "@/lib/timeline/bookmarks";
|
||||||
|
|
||||||
export class RemoveBookmarkCommand extends Command {
|
export class RemoveBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private frameTime: number = 0;
|
private frameTime: number = 0;
|
||||||
|
|
||||||
constructor(private time: number) {
|
constructor(private time: number) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
|
|
||||||
if (!activeScene || !activeProject) {
|
if (!activeScene || !activeProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
|
|
||||||
this.frameTime = getFrameTime({
|
this.frameTime = getFrameTime({
|
||||||
time: this.time,
|
time: this.time,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedBookmarks = removeBookmarkFromArray({
|
const updatedBookmarks = removeBookmarkFromArray({
|
||||||
bookmarks: activeScene.bookmarks,
|
bookmarks: activeScene.bookmarks,
|
||||||
frameTime: this.frameTime,
|
frameTime: this.frameTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updatedBookmarks.length === activeScene.bookmarks.length) {
|
if (updatedBookmarks.length === activeScene.bookmarks.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedScenes = updateSceneInArray({
|
const updatedScenes = updateSceneInArray({
|
||||||
scenes,
|
scenes,
|
||||||
sceneId: activeScene.id,
|
sceneId: activeScene.id,
|
||||||
updates: { bookmarks: updatedBookmarks },
|
updates: { bookmarks: updatedBookmarks },
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes) {
|
if (this.savedScenes) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { updateSceneInArray } from "@/lib/scenes";
|
import { updateSceneInArray } from "@/lib/scenes";
|
||||||
|
|
||||||
export class RenameSceneCommand extends Command {
|
export class RenameSceneCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private previousName: string | null = null;
|
private previousName: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private sceneId: string,
|
private sceneId: string,
|
||||||
private newName: string,
|
private newName: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
|
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
|
|
||||||
const scene = scenes.find((s) => s.id === this.sceneId);
|
const scene = scenes.find((s) => s.id === this.sceneId);
|
||||||
if (!scene) {
|
if (!scene) {
|
||||||
console.error("Scene not found:", this.sceneId);
|
console.error("Scene not found:", this.sceneId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.previousName = scene.name;
|
this.previousName = scene.name;
|
||||||
|
|
||||||
const updatedScenes = updateSceneInArray({
|
const updatedScenes = updateSceneInArray({
|
||||||
scenes,
|
scenes,
|
||||||
sceneId: this.sceneId,
|
sceneId: this.sceneId,
|
||||||
updates: { name: this.newName, updatedAt: new Date() },
|
updates: { name: this.newName, updatedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes && this.previousName !== null) {
|
if (this.savedScenes && this.previousName !== null) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,52 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { updateSceneInArray } from "@/lib/scenes";
|
import { updateSceneInArray } from "@/lib/scenes";
|
||||||
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
|
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
|
||||||
|
|
||||||
export class ToggleBookmarkCommand extends Command {
|
export class ToggleBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private frameTime: number = 0;
|
private frameTime: number = 0;
|
||||||
|
|
||||||
constructor(private time: number) {
|
constructor(private time: number) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
|
|
||||||
if (!activeScene || !activeProject) {
|
if (!activeScene || !activeProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
|
|
||||||
this.frameTime = getFrameTime({
|
this.frameTime = getFrameTime({
|
||||||
time: this.time,
|
time: this.time,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedBookmarks = toggleBookmarkInArray({
|
const updatedBookmarks = toggleBookmarkInArray({
|
||||||
bookmarks: activeScene.bookmarks,
|
bookmarks: activeScene.bookmarks,
|
||||||
frameTime: this.frameTime,
|
frameTime: this.frameTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedScenes = updateSceneInArray({
|
const updatedScenes = updateSceneInArray({
|
||||||
scenes,
|
scenes,
|
||||||
sceneId: activeScene.id,
|
sceneId: activeScene.id,
|
||||||
updates: { bookmarks: updatedBookmarks },
|
updates: { bookmarks: updatedBookmarks },
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes) {
|
if (this.savedScenes) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
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