mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb4dff755 | ||
|
|
6d96674dc2 | ||
|
|
0d35499356 | ||
|
|
d6622dc6b3 | ||
|
|
6ec818fc11 | ||
|
|
6daa69635b | ||
|
|
6b1f38a9a3 | ||
|
|
56ca09692a | ||
|
|
e6c6193638 | ||
|
|
685d902740 | ||
|
|
eea6d43c82 |
Generated
+1
-1
@@ -3820,7 +3820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.9"
|
||||
version = "0.2.10"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
|
||||
@@ -1,167 +1,5 @@
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="left" width="120">
|
||||
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
|
||||
</td>
|
||||
<td align="right">
|
||||
<h1>OpenCut</h1>
|
||||
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
<a href="https://vercel.com/oss">
|
||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
||||
</a>
|
||||
|
||||
<a href="https://fal.ai">
|
||||
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
|
||||
</a>
|
||||
|
||||
## Why?
|
||||
|
||||
- **Privacy**: Your videos stay on your device
|
||||
- **Free features**: Most basic CapCut features are now paywalled
|
||||
- **Simple**: People want editors that are easy to use - CapCut proved that
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `apps/web/`: Next.js web application
|
||||
- `apps/desktop/`: Native desktop app built with GPUI (in progress)
|
||||
- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
|
||||
- `docs/`: Architecture and subsystem documentation
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/docs/installation)
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork and clone the repository
|
||||
|
||||
2. Copy the environment file:
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp apps/web/.env.example apps/web/.env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item apps/web/.env.example apps/web/.env.local
|
||||
```
|
||||
|
||||
3. Start the database and Redis:
|
||||
|
||||
```bash
|
||||
docker compose up -d db redis serverless-redis-http
|
||||
```
|
||||
|
||||
4. Install dependencies and start the dev server:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun dev:web
|
||||
```
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
|
||||
|
||||
### Desktop setup
|
||||
|
||||
Desktop is opt-in. If you're only working on the web app, skip this entirely.
|
||||
|
||||
If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
|
||||
|
||||
### Local WASM development
|
||||
|
||||
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
||||
|
||||
**Prerequisites** — install these once before anything else:
|
||||
|
||||
```bash
|
||||
# Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# build the WASM package
|
||||
cargo install wasm-pack
|
||||
|
||||
# reruns the build on file changes, used by bun dev:wasm
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
1. Build the package once from the repo root:
|
||||
|
||||
```bash
|
||||
bun run build:wasm
|
||||
```
|
||||
|
||||
2. Register the generated package for linking:
|
||||
|
||||
```bash
|
||||
cd rust/wasm/pkg
|
||||
bun link
|
||||
```
|
||||
|
||||
3. Link `apps/web` to the local package:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun link opencut-wasm
|
||||
```
|
||||
|
||||
4. Rebuild on changes while you work:
|
||||
|
||||
```bash
|
||||
bun dev:wasm
|
||||
```
|
||||
|
||||
To switch `apps/web` back to the published package, run:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun add opencut-wasm
|
||||
```
|
||||
|
||||
### Self-Hosting with Docker
|
||||
|
||||
To run everything (including a production build of the app) in Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app will be available at [http://localhost:3100](http://localhost:3100).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
|
||||
|
||||
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
|
||||
|
||||
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
|
||||
|
||||
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
|
||||
|
||||
**Quick start for contributors:**
|
||||
|
||||
- Fork the repo and clone locally
|
||||
- Follow the setup instructions in CONTRIBUTING.md
|
||||
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
|
||||
- Create a feature branch and submit a PR
|
||||
|
||||
## License
|
||||
|
||||
[MIT LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||

|
||||
# OpenCut (Legacy)
|
||||
|
||||
This is the original OpenCut codebase. It's archived and no longer maintained.
|
||||
|
||||
The rewrite is happening at [opencut-app/opencut](https://github.com/opencut-app/opencut).
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.2.9",
|
||||
"opencut-wasm": "^0.2.10",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
@@ -5,7 +5,16 @@ import { useTimelineStore } from "@/timeline/timeline-store";
|
||||
import { useActionHandler } from "@/actions/use-action-handler";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
maxMediaTime,
|
||||
mediaTime,
|
||||
mediaTimeFromSeconds,
|
||||
minMediaTime,
|
||||
subMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
|
||||
import { getElementsAtTime, hasMediaId } from "@/timeline";
|
||||
import { cancelInteraction } from "@/editor/cancel-interaction";
|
||||
@@ -83,7 +92,7 @@ export function useEditorActions() {
|
||||
if (editor.playback.getIsPlaying()) {
|
||||
editor.playback.toggle();
|
||||
}
|
||||
editor.playback.seek({ time: 0 });
|
||||
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -92,11 +101,15 @@ export function useEditorActions() {
|
||||
"seek-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
time: minMediaTime({
|
||||
a: editor.timeline.getTotalDuration(),
|
||||
b: addMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -106,8 +119,15 @@ export function useEditorActions() {
|
||||
"seek-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -117,15 +137,20 @@ export function useEditorActions() {
|
||||
"frame-step-forward",
|
||||
() => {
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + ticksPerFrame,
|
||||
const ticksPerFrame = mediaTime({
|
||||
ticks: Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
),
|
||||
});
|
||||
editor.playback.seek({
|
||||
time: minMediaTime({
|
||||
a: editor.timeline.getTotalDuration(),
|
||||
b: addMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: ticksPerFrame,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -134,11 +159,19 @@ export function useEditorActions() {
|
||||
"frame-step-backward",
|
||||
() => {
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
const ticksPerFrame = mediaTime({
|
||||
ticks: Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
),
|
||||
});
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: ticksPerFrame,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -148,11 +181,15 @@ export function useEditorActions() {
|
||||
"jump-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
time: minMediaTime({
|
||||
a: editor.timeline.getTotalDuration(),
|
||||
b: addMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -162,8 +199,15 @@ export function useEditorActions() {
|
||||
"jump-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -172,7 +216,7 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"goto-start",
|
||||
() => {
|
||||
editor.playback.seek({ time: 0 });
|
||||
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import type { ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { DynamicAnimationPathValue, NumericSpec } from "./types";
|
||||
|
||||
export function getAnimationParamValueKind({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ScalarAnimationKey } from "@/model/decorations/animations";
|
||||
import type { ScalarAnimationKey } from "@/animation/types";
|
||||
|
||||
const BEZIER_SOLVE_ITERATIONS = 20;
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { converter, formatHex, formatHex8, parse } from "culori";
|
||||
import type { AnimationBindingComponent, AnimationBindingInstance, AnimationBindingKind, AnimationBindingOfKind, AnimationPath, AnimationValue, ColorAnimationBinding, DiscreteAnimationBinding, DiscreteValue, NumberAnimationBinding, Vector2AnimationBinding, VectorValue } from "@/model/decorations/animations";
|
||||
import type {
|
||||
AnimationBindingComponent,
|
||||
AnimationBindingOfKind,
|
||||
AnimationBindingInstance,
|
||||
AnimationBindingKind,
|
||||
ColorAnimationBinding,
|
||||
DiscreteAnimationBinding,
|
||||
NumberAnimationBinding,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
DiscreteValue,
|
||||
Vector2AnimationBinding,
|
||||
VectorValue,
|
||||
} from "@/animation/types";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
interface LinearRgba {
|
||||
|
||||
@@ -2,7 +2,12 @@ import {
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
} from "@/animation/bezier";
|
||||
import type { CurveHandle, NormalizedCubicBezier, ScalarAnimationKey } from "@/model/decorations/animations";
|
||||
import type {
|
||||
CurveHandle,
|
||||
NormalizedCubicBezier,
|
||||
ScalarAnimationKey,
|
||||
} from "@/animation/types";
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
|
||||
const VALUE_EPSILON = 1e-6;
|
||||
|
||||
@@ -80,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
|
||||
|
||||
return {
|
||||
rightHandle: {
|
||||
dt: spanTime * x1,
|
||||
dt: roundMediaTime({ time: spanTime * x1 }),
|
||||
dv: effectiveSpanValue * y1,
|
||||
},
|
||||
leftHandle: {
|
||||
dt: spanTime * (x2 - 1),
|
||||
dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
|
||||
dv: effectiveSpanValue * (y2 - 1),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { EffectParamPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import type { ParamValues } from "@/model/decorations/param-values";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
EffectParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { removeElementKeyframe } from "./keyframes";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { AnimationBindingInstance, AnimationPath, ElementAnimations, ScalarAnimationChannel } from "@/model/decorations/animations";
|
||||
import type { ScalarGraphChannel, ScalarGraphKeyframeContext } from "@/animation/types";
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ScalarAnimationChannel,
|
||||
ScalarGraphChannel,
|
||||
ScalarGraphKeyframeContext,
|
||||
} from "@/animation/types";
|
||||
|
||||
export interface EditableScalarChannels {
|
||||
binding: AnimationBindingInstance;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ElementAnimations, GraphicParamPath } from "@/model/decorations/animations";
|
||||
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
@@ -71,4 +72,16 @@ export {
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
isAnimationPropertyPath,
|
||||
} from "./path";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import type { AnimationChannel, AnimationInterpolation, AnimationValue, DiscreteAnimationChannel, DiscreteValue, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType } from "@/model/decorations/animations";
|
||||
import type {
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteValue,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
} from "@/animation/types";
|
||||
import { mediaTime } from "@/wasm";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
@@ -54,9 +64,13 @@ function normalizeRightHandle({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||
const span = mediaTime({
|
||||
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||
});
|
||||
return {
|
||||
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||
dt: mediaTime({
|
||||
ticks: Math.min(span, Math.max(0, handle.dt)),
|
||||
}),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
@@ -74,9 +88,13 @@ function normalizeLeftHandle({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||
const span = mediaTime({
|
||||
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||
});
|
||||
return {
|
||||
dt: Math.max(-span, Math.min(0, handle.dt)),
|
||||
dt: mediaTime({
|
||||
ticks: Math.max(-span, Math.min(0, handle.dt)),
|
||||
}),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { AnimationBindingInstance, AnimationChannel, AnimationPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import type { ElementKeyframe } from "@/animation/types";
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationChannel,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import type { AnimationBindingInstance, AnimationBindingKind, AnimationChannel, AnimationInterpolation, AnimationPath, AnimationValue, DiscreteAnimationChannel, DiscreteAnimationKey, ElementAnimations, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType } from "@/model/decorations/animations";
|
||||
import type { ScalarCurveKeyframePatch } from "@/animation/types";
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationBindingKind,
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteAnimationKey,
|
||||
ElementAnimations,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarCurveKeyframePatch,
|
||||
ScalarSegmentType,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
cloneAnimationBinding,
|
||||
createAnimationBinding,
|
||||
@@ -16,6 +30,16 @@ import {
|
||||
getScalarSegmentInterpolation,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
} from "./property-registry";
|
||||
import {
|
||||
type MediaTime,
|
||||
roundMediaTime,
|
||||
subMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
|
||||
function isNearlySameTime({
|
||||
@@ -153,7 +177,7 @@ function createScalarKey({
|
||||
previousKey,
|
||||
}: {
|
||||
id: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: number;
|
||||
interpolation?: AnimationInterpolation;
|
||||
previousKey?: ScalarAnimationKey;
|
||||
@@ -177,7 +201,7 @@ function createDiscreteKey({
|
||||
value,
|
||||
}: {
|
||||
id: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: string | boolean;
|
||||
}): DiscreteAnimationKey {
|
||||
return {
|
||||
@@ -223,7 +247,7 @@ function getTargetKeyMetadata({
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
keyframeId?: string;
|
||||
}) {
|
||||
const normalizedChannel =
|
||||
@@ -262,7 +286,7 @@ function upsertDiscreteChannelKey({
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: DiscreteAnimationChannel | undefined;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: string | boolean;
|
||||
keyframeId?: string;
|
||||
}): DiscreteAnimationChannel {
|
||||
@@ -319,7 +343,7 @@ function upsertScalarChannelKey({
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel | undefined;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: number;
|
||||
interpolation?: AnimationInterpolation;
|
||||
defaultInterpolation?: AnimationInterpolation;
|
||||
@@ -424,7 +448,7 @@ export function upsertPathKeyframe({
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -509,6 +533,47 @@ export function upsertPathKeyframe({
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const coercedValue = coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value: coercedValue,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value: nextValue,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertKeyframe({
|
||||
channel,
|
||||
time,
|
||||
@@ -517,7 +582,7 @@ export function upsertKeyframe({
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -583,7 +648,7 @@ export function retimeKeyframe({
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}): AnimationChannel | undefined {
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
@@ -828,7 +893,7 @@ export function clampAnimationsToDuration({
|
||||
duration,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
duration: number;
|
||||
duration: MediaTime;
|
||||
}): ElementAnimations | undefined {
|
||||
if (!animations || duration <= 0) {
|
||||
return undefined;
|
||||
@@ -864,7 +929,7 @@ function splitDiscreteChannelAtTime({
|
||||
shouldIncludeSplitBoundary,
|
||||
}: {
|
||||
channel: DiscreteAnimationChannel | undefined;
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
leftBoundaryId: string;
|
||||
rightBoundaryId: string;
|
||||
shouldIncludeSplitBoundary: boolean;
|
||||
@@ -880,7 +945,10 @@ function splitDiscreteChannelAtTime({
|
||||
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
|
||||
let rightKeys = normalizedChannel.keys
|
||||
.filter((key) => key.time >= splitTime)
|
||||
.map((key) => ({ ...key, time: key.time - splitTime }));
|
||||
.map((key) => ({
|
||||
...key,
|
||||
time: subMediaTime({ a: key.time, b: splitTime }),
|
||||
}));
|
||||
|
||||
if (shouldIncludeSplitBoundary) {
|
||||
const hasBoundaryOnLeft = leftKeys.some((key) =>
|
||||
@@ -908,7 +976,7 @@ function splitDiscreteChannelAtTime({
|
||||
rightKeys = [
|
||||
createDiscreteKey({
|
||||
id: rightBoundaryId,
|
||||
time: 0,
|
||||
time: ZERO_MEDIA_TIME,
|
||||
value: boundaryValue as string | boolean,
|
||||
}),
|
||||
...rightKeys,
|
||||
@@ -934,7 +1002,7 @@ function splitScalarChannelAtTime({
|
||||
shouldIncludeSplitBoundary,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel | undefined;
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
leftBoundaryId: string;
|
||||
rightBoundaryId: string;
|
||||
shouldIncludeSplitBoundary: boolean;
|
||||
@@ -950,7 +1018,10 @@ function splitScalarChannelAtTime({
|
||||
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
|
||||
let rightKeys = normalizedChannel.keys
|
||||
.filter((key) => key.time >= splitTime)
|
||||
.map((key) => ({ ...key, time: key.time - splitTime }));
|
||||
.map((key) => ({
|
||||
...key,
|
||||
time: subMediaTime({ a: key.time, b: splitTime }),
|
||||
}));
|
||||
|
||||
const hasBoundaryOnLeft = leftKeys.some((key) =>
|
||||
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
|
||||
@@ -1030,7 +1101,7 @@ function splitScalarChannelAtTime({
|
||||
{
|
||||
...leftKey,
|
||||
rightHandle: {
|
||||
dt: q0.x - p0.x,
|
||||
dt: roundMediaTime({ time: q0.x - p0.x }),
|
||||
dv: q0.y - p0.y,
|
||||
},
|
||||
},
|
||||
@@ -1039,7 +1110,7 @@ function splitScalarChannelAtTime({
|
||||
time: splitTime,
|
||||
value: boundaryValue,
|
||||
leftHandle: {
|
||||
dt: r0.x - splitPoint.x,
|
||||
dt: roundMediaTime({ time: r0.x - splitPoint.x }),
|
||||
dv: r0.y - splitPoint.y,
|
||||
},
|
||||
segmentToNext: leftKey.segmentToNext,
|
||||
@@ -1049,10 +1120,10 @@ function splitScalarChannelAtTime({
|
||||
rightKeys = [
|
||||
{
|
||||
id: rightBoundaryId,
|
||||
time: 0,
|
||||
time: ZERO_MEDIA_TIME,
|
||||
value: boundaryValue,
|
||||
rightHandle: {
|
||||
dt: r1.x - splitPoint.x,
|
||||
dt: roundMediaTime({ time: r1.x - splitPoint.x }),
|
||||
dv: r1.y - splitPoint.y,
|
||||
},
|
||||
segmentToNext: "bezier",
|
||||
@@ -1060,9 +1131,9 @@ function splitScalarChannelAtTime({
|
||||
},
|
||||
{
|
||||
...rightKey,
|
||||
time: rightKey.time - splitTime,
|
||||
time: subMediaTime({ a: rightKey.time, b: splitTime }),
|
||||
leftHandle: {
|
||||
dt: q2.x - p3.x,
|
||||
dt: roundMediaTime({ time: q2.x - p3.x }),
|
||||
dv: q2.y - p3.y,
|
||||
},
|
||||
},
|
||||
@@ -1070,7 +1141,7 @@ function splitScalarChannelAtTime({
|
||||
.filter((key) => key.time > rightKey.time)
|
||||
.map((key) => ({
|
||||
...key,
|
||||
time: key.time - splitTime,
|
||||
time: subMediaTime({ a: key.time, b: splitTime }),
|
||||
})),
|
||||
];
|
||||
} else {
|
||||
@@ -1086,7 +1157,7 @@ function splitScalarChannelAtTime({
|
||||
rightKeys = [
|
||||
createScalarKey({
|
||||
id: rightBoundaryId,
|
||||
time: 0,
|
||||
time: ZERO_MEDIA_TIME,
|
||||
value: boundaryValue,
|
||||
interpolation: getScalarSegmentInterpolation({
|
||||
segment: leftKey.segmentToNext,
|
||||
@@ -1142,7 +1213,7 @@ export function splitAnimationsAtTime({
|
||||
shouldIncludeSplitBoundary = true,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
shouldIncludeSplitBoundary?: boolean;
|
||||
}): {
|
||||
leftAnimations: ElementAnimations | undefined;
|
||||
@@ -1258,7 +1329,7 @@ export function retimeElementKeyframe({
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}): ElementAnimations | undefined {
|
||||
const binding = getBinding({ animations, propertyPath });
|
||||
if (!binding) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnimationPath, AnimationPropertyPath } from "@/model/decorations/animations";
|
||||
import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
|
||||
import { ANIMATION_PROPERTY_PATHS } from "./types";
|
||||
import { isEffectParamPath } from "./effect-param-channel";
|
||||
import { isGraphicParamPath } from "./graphic-param-channel";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AnimationPropertyGroup, AnimationPropertyPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import { ANIMATION_PROPERTY_GROUPS } from "@/model/decorations/animations";
|
||||
import type {
|
||||
AnimationPropertyGroup,
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import { ANIMATION_PROPERTY_GROUPS } from "@/animation/types";
|
||||
import { getKeyframeAtTime } from "./keyframe-query";
|
||||
|
||||
export interface GroupKeyframeRef {
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import type {
|
||||
AnimationBindingKind,
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
} from "@/animation/types";
|
||||
import { parseColorToLinearRgba } from "./binding-values";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/text/background";
|
||||
import {
|
||||
canElementHaveAudio,
|
||||
isVisualElement,
|
||||
} from "@/timeline/element-utils";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface AnimationPropertyDefinition {
|
||||
kind: AnimationBindingKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: AnimationValue;
|
||||
}) => TimelineElement;
|
||||
}
|
||||
|
||||
function applyNumericSpec({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: number;
|
||||
numericRange: NumericSpec | undefined;
|
||||
}): number {
|
||||
if (!numericRange) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const steppedValue =
|
||||
numericRange.step != null
|
||||
? snapToStep({ value, step: numericRange.step })
|
||||
: value;
|
||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
function coerceNumberValue({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: AnimationValue;
|
||||
numericRange?: NumericSpec;
|
||||
}): number | null {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return applyNumericSpec({ value, numericRange });
|
||||
}
|
||||
|
||||
function coerceColorValue({
|
||||
value,
|
||||
}: {
|
||||
value: AnimationValue;
|
||||
}): string | null {
|
||||
return typeof value === "string" && parseColorToLinearRgba({ color: value })
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function createNumberPropertyDefinition({
|
||||
numericRange,
|
||||
supportsElement,
|
||||
getValue,
|
||||
setValue,
|
||||
}: {
|
||||
numericRange?: NumericSpec;
|
||||
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
||||
getValue: AnimationPropertyDefinition["getValue"];
|
||||
setValue: AnimationPropertyDefinition["setValue"];
|
||||
}): AnimationPropertyDefinition {
|
||||
return {
|
||||
kind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRanges: numericRange ? { value: numericRange } : undefined,
|
||||
supportsElement,
|
||||
getValue,
|
||||
coerceValue: ({ value }) =>
|
||||
coerceNumberValue({
|
||||
value,
|
||||
numericRange,
|
||||
}),
|
||||
setValue,
|
||||
};
|
||||
}
|
||||
|
||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyDefinition
|
||||
> = {
|
||||
"transform.positionX": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position.x : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, x: value as number },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.positionY": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position.y : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, y: value as number },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.scaleX": createNumberPropertyDefinition({
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleX : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleX: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.scaleY": createNumberPropertyDefinition({
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleY : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleY: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.rotate": createNumberPropertyDefinition({
|
||||
numericRange: { min: -360, max: 360, step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.rotate : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, rotate: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
opacity: createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, max: 1, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.opacity : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, opacity: value as number }
|
||||
: element,
|
||||
}),
|
||||
volume: createNumberPropertyDefinition({
|
||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||
supportsElement: ({ element }) => canElementHaveAudio(element),
|
||||
getValue: ({ element }) =>
|
||||
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
||||
setValue: ({ element, value }) =>
|
||||
canElementHaveAudio(element)
|
||||
? { ...element, volume: value as number }
|
||||
: element,
|
||||
}),
|
||||
color: {
|
||||
kind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? { ...element, color: value as string }
|
||||
: element,
|
||||
},
|
||||
"background.color": {
|
||||
kind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text" ? element.background.color : null,
|
||||
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, color: value as string },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.paddingX": createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingX: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.paddingY": createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingY: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.offsetX": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetX: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.offsetY": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetY: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.cornerRadius": createNumberPropertyDefinition({
|
||||
numericRange: {
|
||||
min: CORNER_RADIUS_MIN,
|
||||
max: CORNER_RADIUS_MAX,
|
||||
step: 1,
|
||||
},
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, cornerRadius: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
};
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
||||
}
|
||||
|
||||
export function getAnimationPropertyDefinition({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationPropertyDefinition {
|
||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||
}
|
||||
|
||||
export function supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.supportsElement({ element });
|
||||
}
|
||||
|
||||
export function getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationValue | null {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
return definition.getValue({ element });
|
||||
}
|
||||
|
||||
export function withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): TimelineElement {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
const coercedValue = definition.coerceValue({ value });
|
||||
if (coercedValue === null || !definition.supportsElement({ element })) {
|
||||
return element;
|
||||
}
|
||||
return definition.setValue({ element, value: coercedValue });
|
||||
}
|
||||
|
||||
export function getDefaultInterpolationForProperty({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationInterpolation {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.defaultInterpolation;
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): AnimationValue | null {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.coerceValue({ value });
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AnimationPath, AnimationValueForPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationValueForPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
|
||||
+185
-58
@@ -1,62 +1,189 @@
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
CurveHandle,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
TangentMode,
|
||||
} from "@/model/decorations/animations";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export type {
|
||||
AnimationBindingByKind,
|
||||
AnimationBindingComponent,
|
||||
AnimationBindingInstance,
|
||||
AnimationBindingKind,
|
||||
AnimationBindingOfKind,
|
||||
AnimationChannel,
|
||||
AnimationColorPropertyPath,
|
||||
AnimationInterpolation,
|
||||
AnimationKeyframe,
|
||||
AnimationNumericPropertyPath,
|
||||
AnimationPath,
|
||||
AnimationPropertyGroup,
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyValueMap,
|
||||
AnimationValue,
|
||||
AnimationValueForPath,
|
||||
ChannelExtrapolationMode,
|
||||
ColorAnimationBinding,
|
||||
ContinuousKeyframeInterpolation,
|
||||
CurveHandle,
|
||||
DiscreteAnimationBinding,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteAnimationKey,
|
||||
DiscreteKeyframeInterpolation,
|
||||
DiscreteValue,
|
||||
DynamicAnimationPathValue,
|
||||
EffectParamPath,
|
||||
ElementAnimationBindingMap,
|
||||
ElementAnimationChannelMap,
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
NormalizedCubicBezier,
|
||||
NumberAnimationBinding,
|
||||
NumericSpec,
|
||||
PrimitiveAnimationChannelKind,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
TangentMode,
|
||||
Vector2AnimationBinding,
|
||||
VectorValue,
|
||||
} from "@/model/decorations/animations";
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.positionX",
|
||||
"transform.positionY",
|
||||
"transform.scaleX",
|
||||
"transform.scaleY",
|
||||
"transform.rotate",
|
||||
"opacity",
|
||||
"volume",
|
||||
"color",
|
||||
"background.color",
|
||||
"background.paddingX",
|
||||
"background.paddingY",
|
||||
"background.offsetX",
|
||||
"background.offsetY",
|
||||
"background.cornerRadius",
|
||||
] as const;
|
||||
|
||||
export {
|
||||
ANIMATION_PROPERTY_GROUPS,
|
||||
ANIMATION_PROPERTY_PATHS,
|
||||
} from "@/model/decorations/animations";
|
||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||
export type GraphicParamPath = `params.${string}`;
|
||||
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||
export type AnimationPath =
|
||||
| AnimationPropertyPath
|
||||
| GraphicParamPath
|
||||
| EffectParamPath;
|
||||
|
||||
export const ANIMATION_PROPERTY_GROUPS = {
|
||||
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
||||
|
||||
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||
|
||||
export type VectorValue = { x: number; y: number };
|
||||
export type DiscreteValue = boolean | string;
|
||||
export type AnimationValue = number | string | boolean | VectorValue;
|
||||
export interface AnimationPropertyValueMap {
|
||||
"transform.positionX": number;
|
||||
"transform.positionY": number;
|
||||
"transform.scaleX": number;
|
||||
"transform.scaleY": number;
|
||||
"transform.rotate": number;
|
||||
opacity: number;
|
||||
volume: number;
|
||||
color: string;
|
||||
"background.color": string;
|
||||
"background.paddingX": number;
|
||||
"background.paddingY": number;
|
||||
"background.offsetX": number;
|
||||
"background.offsetY": number;
|
||||
"background.cornerRadius": number;
|
||||
}
|
||||
export type DynamicAnimationPathValue = number | string | boolean;
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
export type AnimationValueForPath<TPath extends AnimationPath> =
|
||||
TPath extends AnimationPropertyPath
|
||||
? AnimationPropertyValueMap[TPath]
|
||||
: TPath extends GraphicParamPath | EffectParamPath
|
||||
? DynamicAnimationPathValue
|
||||
: never;
|
||||
export type AnimationNumericPropertyPath = {
|
||||
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
|
||||
}[AnimationPropertyPath];
|
||||
export type AnimationColorPropertyPath = {
|
||||
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
|
||||
}[AnimationPropertyPath];
|
||||
|
||||
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||
export type DiscreteKeyframeInterpolation = "hold";
|
||||
export type AnimationInterpolation =
|
||||
| ContinuousKeyframeInterpolation
|
||||
| DiscreteKeyframeInterpolation;
|
||||
|
||||
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
|
||||
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
|
||||
export type ScalarSegmentType = "step" | "linear" | "bezier";
|
||||
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
|
||||
export type ChannelExtrapolationMode = "hold" | "linear";
|
||||
|
||||
export interface CurveHandle {
|
||||
dt: MediaTime;
|
||||
dv: number;
|
||||
}
|
||||
|
||||
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
|
||||
id: string;
|
||||
time: MediaTime; // relative to element start time
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
|
||||
leftHandle?: CurveHandle;
|
||||
rightHandle?: CurveHandle;
|
||||
segmentToNext: ScalarSegmentType;
|
||||
tangentMode: TangentMode;
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationKey
|
||||
extends BaseAnimationKeyframe<DiscreteValue> {}
|
||||
|
||||
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
|
||||
|
||||
export interface ScalarAnimationChannel {
|
||||
kind: "scalar";
|
||||
keys: ScalarAnimationKey[];
|
||||
extrapolation?: {
|
||||
before: ChannelExtrapolationMode;
|
||||
after: ChannelExtrapolationMode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationChannel {
|
||||
kind: "discrete";
|
||||
keys: DiscreteAnimationKey[];
|
||||
}
|
||||
|
||||
export type AnimationChannel =
|
||||
| ScalarAnimationChannel
|
||||
| DiscreteAnimationChannel;
|
||||
|
||||
export type ElementAnimationChannelMap = Record<
|
||||
string,
|
||||
AnimationChannel | undefined
|
||||
>;
|
||||
|
||||
export interface AnimationBindingComponent<TKey extends string = string> {
|
||||
key: TKey;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
interface BaseAnimationBinding<
|
||||
TKind extends AnimationBindingKind,
|
||||
TComponentKey extends string,
|
||||
> {
|
||||
path: AnimationPath;
|
||||
kind: TKind;
|
||||
components: AnimationBindingComponent<TComponentKey>[];
|
||||
}
|
||||
|
||||
export interface NumberAnimationBinding
|
||||
extends BaseAnimationBinding<"number", "value"> {}
|
||||
|
||||
export interface Vector2AnimationBinding
|
||||
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
|
||||
|
||||
export interface ColorAnimationBinding
|
||||
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
|
||||
colorSpace: "srgb-linear";
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationBinding
|
||||
extends BaseAnimationBinding<"discrete", "value"> {}
|
||||
|
||||
export type AnimationBindingInstance =
|
||||
| NumberAnimationBinding
|
||||
| Vector2AnimationBinding
|
||||
| ColorAnimationBinding
|
||||
| DiscreteAnimationBinding;
|
||||
|
||||
export interface AnimationBindingByKind {
|
||||
number: NumberAnimationBinding;
|
||||
vector2: Vector2AnimationBinding;
|
||||
color: ColorAnimationBinding;
|
||||
discrete: DiscreteAnimationBinding;
|
||||
}
|
||||
|
||||
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
|
||||
AnimationBindingByKind[TKind];
|
||||
|
||||
export type ElementAnimationBindingMap = Record<
|
||||
string,
|
||||
AnimationBindingInstance | undefined
|
||||
>;
|
||||
|
||||
export interface ElementAnimations {
|
||||
bindings: ElementAnimationBindingMap;
|
||||
channels: ElementAnimationChannelMap;
|
||||
}
|
||||
|
||||
export type NormalizedCubicBezier = [number, number, number, number];
|
||||
|
||||
export interface ScalarGraphChannelTarget {
|
||||
propertyPath: AnimationPath;
|
||||
@@ -89,7 +216,7 @@ export interface ScalarCurveKeyframePatch {
|
||||
export interface ElementKeyframe {
|
||||
propertyPath: AnimationPath;
|
||||
id: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { getKeyframeById } from "@/animation";
|
||||
import type { SelectedKeyframeRef } from "@/animation/types";
|
||||
import { elementId, type ElementRef, type TimelineElement } from "@/model";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { PasteKeyframesCommand } from "@/commands/timeline";
|
||||
import type {
|
||||
ClipboardHandler,
|
||||
KeyframeClipboardCurvePatch,
|
||||
KeyframeClipboardItem,
|
||||
} from "../types";
|
||||
import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm";
|
||||
|
||||
function resolveSingleSourceElement({
|
||||
selectedKeyframes,
|
||||
}: {
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
}): ElementRef | null {
|
||||
}) {
|
||||
const firstKeyframe = selectedKeyframes[0];
|
||||
if (!firstKeyframe) {
|
||||
return null;
|
||||
@@ -20,7 +21,7 @@ function resolveSingleSourceElement({
|
||||
|
||||
const sourceElement = {
|
||||
trackId: firstKeyframe.trackId,
|
||||
elementId: elementId(firstKeyframe.elementId),
|
||||
elementId: firstKeyframe.elementId,
|
||||
};
|
||||
const isSingleSource = selectedKeyframes.every(
|
||||
(keyframe) =>
|
||||
@@ -80,7 +81,7 @@ function buildClipboardItem({
|
||||
element: TimelineElement;
|
||||
propertyPath: KeyframeClipboardItem["propertyPath"];
|
||||
keyframeId: string;
|
||||
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
|
||||
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
@@ -136,10 +137,11 @@ export const KeyframesClipboardHandler = {
|
||||
}
|
||||
|
||||
const minTime = Math.min(...rawItems.map((item) => item.time));
|
||||
const minTimeMedia = roundMediaTime({ time: minTime });
|
||||
const items = rawItems
|
||||
.map(({ time, ...item }) => ({
|
||||
...item,
|
||||
timeOffset: time - minTime,
|
||||
timeOffset: subMediaTime({ a: time, b: minTimeMedia }),
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { AnimationInterpolation, AnimationPath, AnimationValue } from "@/model/decorations/animations";
|
||||
import type { ScalarCurveKeyframePatch, SelectedKeyframeRef } from "@/animation/types";
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
ScalarCurveKeyframePatch,
|
||||
SelectedKeyframeRef,
|
||||
} from "@/animation/types";
|
||||
import type { Command } from "@/commands/base-command";
|
||||
import type { CreateTimelineElement, ElementRef, TrackType } from "@/model";
|
||||
import type {
|
||||
CreateTimelineElement,
|
||||
ElementRef,
|
||||
TrackType,
|
||||
} from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export interface ElementClipboardItem {
|
||||
trackId: string;
|
||||
@@ -17,7 +27,7 @@ export interface KeyframeClipboardCurvePatch {
|
||||
|
||||
export interface KeyframeClipboardItem {
|
||||
propertyPath: AnimationPath;
|
||||
timeOffset: number;
|
||||
timeOffset: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
curvePatches: KeyframeClipboardCurvePatch[];
|
||||
@@ -52,7 +62,7 @@ export interface PasteContext {
|
||||
editor: EditorCore;
|
||||
selectedElements: ElementRef[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}
|
||||
|
||||
export interface ClipboardHandler<TType extends ClipboardEntryType> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EditorSelectionPatch } from "@/selection/editor-selection";
|
||||
import type { ElementRef } from "@/model";
|
||||
import type { ElementRef } from "@/timeline/types";
|
||||
|
||||
export interface CommandResult {
|
||||
selection?: EditorSelectionPatch;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { storageService } from "@/services/storage/service";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
import { waveformCache } from "@/services/waveform-cache/service";
|
||||
import { hasMediaId } from "@/timeline/element-utils";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
|
||||
export class RemoveMediaAssetCommand extends Command {
|
||||
private savedAssets: MediaAsset[] | null = null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/model";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { buildDefaultScene } from "@/timeline/scenes";
|
||||
|
||||
export class CreateSceneCommand extends Command {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/model";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/timeline/scenes";
|
||||
|
||||
export class DeleteSceneCommand extends Command {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/model";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { updateSceneInArray } from "@/timeline/scenes";
|
||||
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class MoveBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
|
||||
constructor(
|
||||
private fromTime: number,
|
||||
private toTime: number,
|
||||
private fromTime: MediaTime,
|
||||
private toTime: MediaTime,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/model";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { updateSceneInArray } from "@/timeline/scenes";
|
||||
import {
|
||||
getFrameTime,
|
||||
removeBookmarkFromArray,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class RemoveBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private frameTime: number = 0;
|
||||
private frameTime: MediaTime = 0 as MediaTime;
|
||||
|
||||
constructor(private time: number) {
|
||||
constructor(private time: MediaTime) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/model";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { updateSceneInArray } from "@/timeline/scenes";
|
||||
|
||||
export class RenameSceneCommand extends Command {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/model";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { updateSceneInArray } from "@/timeline/scenes";
|
||||
import {
|
||||
getFrameTime,
|
||||
toggleBookmarkInArray,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class ToggleBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private frameTime: number = 0;
|
||||
private frameTime: MediaTime = 0 as MediaTime;
|
||||
|
||||
constructor(private time: number) {
|
||||
constructor(private time: MediaTime) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { Bookmark, TScene } from "@/model";
|
||||
import type { Bookmark, TScene } from "@/timeline";
|
||||
import { updateSceneInArray } from "@/timeline/scenes";
|
||||
import {
|
||||
getFrameTime,
|
||||
updateBookmarkInArray,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class UpdateBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
|
||||
constructor(
|
||||
private time: number,
|
||||
private time: MediaTime,
|
||||
private updates: Partial<Omit<Bookmark, "time">>,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -6,10 +6,17 @@ import {
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { KeyframeClipboardItem } from "@/clipboard";
|
||||
import type { SceneTracks, TimelineElement } from "@/model";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
addMediaTime,
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
function pasteKeyframesIntoElement({
|
||||
element,
|
||||
@@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({
|
||||
clipboardItems,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
clipboardItems: KeyframeClipboardItem[];
|
||||
}): TimelineElement {
|
||||
let nextElement = element;
|
||||
@@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyframeTime = Math.max(
|
||||
0,
|
||||
Math.min(time + item.timeOffset, nextElement.duration),
|
||||
);
|
||||
const keyframeTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({
|
||||
a: addMediaTime({ a: time, b: item.timeOffset }),
|
||||
b: nextElement.duration,
|
||||
}),
|
||||
});
|
||||
const nextAnimations = upsertPathKeyframe({
|
||||
animations: nextElement.animations,
|
||||
propertyPath: item.propertyPath,
|
||||
@@ -79,7 +89,7 @@ export class PasteKeyframesCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly clipboardItems: KeyframeClipboardItem[];
|
||||
|
||||
constructor({
|
||||
@@ -90,7 +100,7 @@ export class PasteKeyframesCommand extends Command {
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
clipboardItems: KeyframeClipboardItem[];
|
||||
}) {
|
||||
super();
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type CommandResult,
|
||||
} from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { ElementRef, SceneTracks, TimelineElement } from "@/model";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import type { ElementClipboardItem } from "@/clipboard";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
@@ -13,18 +13,25 @@ import {
|
||||
enforceMainTrackStart,
|
||||
} from "@/timeline/placement";
|
||||
import { cloneAnimations } from "@/animation";
|
||||
import {
|
||||
addMediaTime,
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
subMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private pastedElements: ElementRef[] = [];
|
||||
private readonly time: number;
|
||||
private pastedElements: { trackId: string; elementId: string }[] = [];
|
||||
private readonly time: MediaTime;
|
||||
private readonly clipboardItems: ElementClipboardItem[];
|
||||
|
||||
constructor({
|
||||
time,
|
||||
clipboardItems,
|
||||
}: {
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
clipboardItems: ElementClipboardItem[];
|
||||
}) {
|
||||
super();
|
||||
@@ -39,8 +46,12 @@ export class PasteCommand extends Command {
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
this.pastedElements = [];
|
||||
|
||||
const minStart = Math.min(
|
||||
...this.clipboardItems.map((item) => item.element.startTime),
|
||||
const minStart = this.clipboardItems.reduce(
|
||||
(earliestStartTime, item) =>
|
||||
item.element.startTime < earliestStartTime
|
||||
? item.element.startTime
|
||||
: earliestStartTime,
|
||||
this.clipboardItems[0].element.startTime,
|
||||
);
|
||||
|
||||
let updatedTracks = this.savedState;
|
||||
@@ -97,12 +108,18 @@ export class PasteCommand extends Command {
|
||||
targetTrackId: targetTrack.id,
|
||||
requestedStartTime: earliestElement.startTime,
|
||||
});
|
||||
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
||||
const delta = subMediaTime({
|
||||
a: adjustedEarliestStartTime,
|
||||
b: earliestElement.startTime,
|
||||
});
|
||||
|
||||
if (delta !== 0) {
|
||||
if (delta !== ZERO_MEDIA_TIME) {
|
||||
elementsForPlacement = elementsToAdd.map((element) => ({
|
||||
...element,
|
||||
startTime: Math.max(0, element.startTime + delta),
|
||||
startTime: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: addMediaTime({ a: element.startTime, b: delta }),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -142,7 +159,7 @@ export class PasteCommand extends Command {
|
||||
}
|
||||
}
|
||||
|
||||
getPastedElements(): ElementRef[] {
|
||||
getPastedElements(): { trackId: string; elementId: string }[] {
|
||||
return this.pastedElements;
|
||||
}
|
||||
}
|
||||
@@ -168,14 +185,20 @@ function buildPastedElements({
|
||||
time,
|
||||
}: {
|
||||
items: ElementClipboardItem[];
|
||||
minStart: number;
|
||||
time: number;
|
||||
minStart: MediaTime;
|
||||
time: MediaTime;
|
||||
}): TimelineElement[] {
|
||||
const elementsToAdd: TimelineElement[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const relativeOffset = item.element.startTime - minStart;
|
||||
const startTime = Math.max(0, time + relativeOffset);
|
||||
const relativeOffset = subMediaTime({
|
||||
a: item.element.startTime,
|
||||
b: minStart,
|
||||
});
|
||||
const startTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: addMediaTime({ a: time, b: relativeOffset }),
|
||||
});
|
||||
const newElementId = generateUUID();
|
||||
|
||||
elementsToAdd.push({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack } from "@/model";
|
||||
import type { TimelineTrack } from "@/timeline";
|
||||
|
||||
function removeTrackElements<TTrack extends TimelineTrack>({
|
||||
track,
|
||||
|
||||
@@ -3,24 +3,19 @@ import {
|
||||
createElementSelectionResult,
|
||||
type CommandResult,
|
||||
} from "@/commands/base-command";
|
||||
import {
|
||||
elementId,
|
||||
type ElementId,
|
||||
type ElementRef,
|
||||
type SceneTracks,
|
||||
type TimelineElement,
|
||||
} from "@/model";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
|
||||
import { cloneAnimations } from "@/animation";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}
|
||||
|
||||
export class DuplicateElementsCommand extends Command {
|
||||
private duplicatedElements: ElementRef[] = [];
|
||||
private duplicatedElements: { trackId: string; elementId: string }[] = [];
|
||||
private savedState: SceneTracks | null = null;
|
||||
private elements: DuplicateElementsParams["elements"];
|
||||
|
||||
@@ -59,7 +54,7 @@ export class DuplicateElementsCommand extends Command {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = elementId(generateUUID());
|
||||
const newId = generateUUID();
|
||||
newTrackElements.push(
|
||||
buildDuplicateElement({
|
||||
element,
|
||||
@@ -113,7 +108,7 @@ export class DuplicateElementsCommand extends Command {
|
||||
}
|
||||
}
|
||||
|
||||
getDuplicatedElements(): ElementRef[] {
|
||||
getDuplicatedElements(): { trackId: string; elementId: string }[] {
|
||||
return this.duplicatedElements;
|
||||
}
|
||||
}
|
||||
@@ -124,8 +119,8 @@ function buildDuplicateElement({
|
||||
startTime,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
id: ElementId;
|
||||
startTime: number;
|
||||
id: string;
|
||||
startTime: MediaTime;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
...element,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { SceneTracks, VisualElement } from "@/model";
|
||||
import type { SceneTracks, VisualElement } from "@/timeline";
|
||||
import { buildDefaultEffectInstance } from "@/effects";
|
||||
|
||||
function addEffectToElement({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { SceneTracks, VisualElement } from "@/model";
|
||||
import type { SceneTracks, VisualElement } from "@/timeline";
|
||||
|
||||
function removeEffectFromElement({
|
||||
element,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { SceneTracks, VisualElement } from "@/model";
|
||||
import type { SceneTracks, VisualElement } from "@/timeline";
|
||||
|
||||
function reorderEffectsOnElement({
|
||||
element,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { SceneTracks, VisualElement } from "@/model";
|
||||
import type { SceneTracks, VisualElement } from "@/timeline";
|
||||
|
||||
export function toggleEffectOnElement({
|
||||
element,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { ParamValues } from "@/model/decorations/param-values";
|
||||
import type { SceneTracks, VisualElement } from "@/model";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { SceneTracks, VisualElement } from "@/timeline";
|
||||
|
||||
function updateEffectParamsOnElement({
|
||||
element,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
elementId,
|
||||
type CreateTimelineElement,
|
||||
type ElementId,
|
||||
type SceneTracks,
|
||||
type TimelineElement,
|
||||
type TrackType,
|
||||
} from "@/model";
|
||||
import type {
|
||||
CreateTimelineElement,
|
||||
SceneTracks,
|
||||
TimelineElement,
|
||||
TrackType,
|
||||
} from "@/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { requiresMediaId } from "@/timeline/element-utils";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
@@ -20,6 +18,7 @@ import {
|
||||
resolveTrackPlacement,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/timeline/placement";
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
|
||||
type InsertElementPlacement =
|
||||
| { mode: "explicit"; trackId: string }
|
||||
@@ -31,13 +30,13 @@ export interface InsertElementParams {
|
||||
}
|
||||
|
||||
export class InsertElementCommand extends Command {
|
||||
private elementId: ElementId;
|
||||
private elementId: string;
|
||||
private savedState: SceneTracks | null = null;
|
||||
private targetTrackId: string | null = null;
|
||||
|
||||
constructor({ element, placement }: InsertElementParams) {
|
||||
super();
|
||||
this.elementId = elementId(generateUUID());
|
||||
this.elementId = generateUUID();
|
||||
this.element = element;
|
||||
this.placement = placement;
|
||||
}
|
||||
@@ -268,7 +267,12 @@ export class InsertElementCommand extends Command {
|
||||
placementResult.kind === "existingTrack"
|
||||
? {
|
||||
...element,
|
||||
startTime: placementResult.adjustedStartTime ?? element.startTime,
|
||||
startTime:
|
||||
placementResult.adjustedStartTime !== undefined
|
||||
? roundMediaTime({
|
||||
time: placementResult.adjustedStartTime,
|
||||
})
|
||||
: element.startTime,
|
||||
}
|
||||
: element;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { removeEffectParamKeyframe } from "@/animation/effect-param-channel";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
|
||||
export class RemoveEffectParamKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { AnimationPath, AnimationValue } from "@/model/decorations/animations";
|
||||
import type { SceneTracks, TimelineElement } from "@/model";
|
||||
import type { AnimationPath, AnimationValue } from "@/animation/types";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
|
||||
function removeKeyframeAndPersist({
|
||||
|
||||
@@ -2,8 +2,14 @@ import { EditorCore } from "@/core";
|
||||
import { retimeElementKeyframe } from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { AnimationPath } from "@/model/decorations/animations";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { AnimationPath } from "@/animation/types";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import {
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
|
||||
export class RetimeKeyframeCommand extends Command {
|
||||
@@ -12,7 +18,7 @@ export class RetimeKeyframeCommand extends Command {
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly keyframeId: string;
|
||||
private readonly nextTime: number;
|
||||
private readonly nextTime: MediaTime;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
@@ -25,7 +31,7 @@ export class RetimeKeyframeCommand extends Command {
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
nextTime: number;
|
||||
nextTime: MediaTime;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
@@ -48,7 +54,10 @@ export class RetimeKeyframeCommand extends Command {
|
||||
return element;
|
||||
}
|
||||
|
||||
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
|
||||
const boundedTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({ a: this.nextTime, b: element.duration }),
|
||||
});
|
||||
return {
|
||||
...element,
|
||||
animations: retimeElementKeyframe({
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type { AnimationPath } from "@/model/decorations/animations";
|
||||
import type { ScalarCurveKeyframePatch } from "@/animation/types";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ScalarCurveKeyframePatch,
|
||||
} from "@/animation/types";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
|
||||
export class UpdateScalarKeyframeCurveCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
@@ -7,8 +7,14 @@ import {
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type { AnimationInterpolation } from "@/model/decorations/animations";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { AnimationInterpolation } from "@/animation/types";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import {
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
@@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
private readonly paramKey: string;
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly value: number | string | boolean;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
@@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: number | string | boolean;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
const boundedTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({ a: this.time, b: element.duration }),
|
||||
});
|
||||
const propertyPath = buildEffectParamPath({
|
||||
effectId: this.effectId,
|
||||
paramKey: this.paramKey,
|
||||
|
||||
@@ -2,16 +2,26 @@ import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { upsertPathKeyframe } from "@/animation";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type { AnimationInterpolation, AnimationPath, AnimationValue } from "@/model/decorations/animations";
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class UpsertKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly value: AnimationValue;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
@@ -28,7 +38,7 @@ export class UpsertKeyframeCommand extends Command {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -60,7 +70,10 @@ export class UpsertKeyframeCommand extends Command {
|
||||
return element;
|
||||
}
|
||||
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
const boundedTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({ a: this.time, b: element.duration }),
|
||||
});
|
||||
return {
|
||||
...element,
|
||||
animations: upsertPathKeyframe({
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
getCustomMaskClosedStateAfterPointRemoval,
|
||||
removeCustomMaskPoints,
|
||||
} from "@/masks/custom-path";
|
||||
import type { CustomMask } from "@/model/decorations/mask";
|
||||
import type { CustomMask } from "@/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/model";
|
||||
import type { MaskableElement, SceneTracks } from "@/timeline";
|
||||
|
||||
function deletePointsFromCustomMask({
|
||||
mask,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom";
|
||||
import type { ElementBounds } from "@/preview/element-bounds";
|
||||
import type { CustomMask } from "@/model/decorations/mask";
|
||||
import type { CustomMask } from "@/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/model";
|
||||
import type { MaskableElement, SceneTracks } from "@/timeline";
|
||||
|
||||
function insertPointIntoCustomMask({
|
||||
mask,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/model";
|
||||
import type { SceneTracks, MaskableElement } from "@/timeline";
|
||||
|
||||
function removeMaskFromElement({
|
||||
element,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { Mask } from "@/model/decorations/mask";
|
||||
import type { MaskableElement, SceneTracks } from "@/model";
|
||||
import type { Mask } from "@/masks/types";
|
||||
import type { SceneTracks, MaskableElement } from "@/timeline";
|
||||
|
||||
export function toggleMaskInvertedOnElement({
|
||||
element,
|
||||
|
||||
@@ -4,12 +4,7 @@ import {
|
||||
type CommandResult,
|
||||
} from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
elementId as toElementId,
|
||||
type SceneTracks,
|
||||
type TimelineElement,
|
||||
type TimelineTrack,
|
||||
} from "@/model";
|
||||
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
validateElementTrackCompatibility,
|
||||
@@ -122,7 +117,7 @@ export class MoveElementCommand extends Command {
|
||||
return createElementSelectionResult(
|
||||
this.moves.map(({ elementId, targetTrackId }) => ({
|
||||
trackId: targetTrackId,
|
||||
elementId: toElementId(elementId),
|
||||
elementId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,24 @@ import {
|
||||
createElementSelectionResult,
|
||||
type CommandResult,
|
||||
} from "@/commands/base-command";
|
||||
import {
|
||||
elementId,
|
||||
type ElementRef,
|
||||
type SceneTracks,
|
||||
type TimelineElement,
|
||||
} from "@/model";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isRetimableElement } from "@/timeline";
|
||||
import { splitAnimationsAtTime } from "@/animation";
|
||||
import { getSourceSpanAtClipTime } from "@/retime";
|
||||
import {
|
||||
addMediaTime,
|
||||
type MediaTime,
|
||||
roundMediaTime,
|
||||
subMediaTime,
|
||||
} from "@/wasm";
|
||||
|
||||
export class SplitElementsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private rightSideElements: ElementRef[] = [];
|
||||
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly splitTime: number;
|
||||
private readonly splitTime: MediaTime;
|
||||
private readonly retainSide: "both" | "left" | "right";
|
||||
|
||||
constructor({
|
||||
@@ -28,7 +29,7 @@ export class SplitElementsCommand extends Command {
|
||||
retainSide = "both",
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}) {
|
||||
super();
|
||||
@@ -37,7 +38,7 @@ export class SplitElementsCommand extends Command {
|
||||
this.retainSide = retainSide;
|
||||
}
|
||||
|
||||
getRightSideElements(): ElementRef[] {
|
||||
getRightSideElements(): { trackId: string; elementId: string }[] {
|
||||
return this.rightSideElements;
|
||||
}
|
||||
|
||||
@@ -78,21 +79,39 @@ export class SplitElementsCommand extends Command {
|
||||
return [element];
|
||||
}
|
||||
|
||||
const relativeTime = this.splitTime - element.startTime;
|
||||
const relativeTime = subMediaTime({
|
||||
a: this.splitTime,
|
||||
b: element.startTime,
|
||||
});
|
||||
const leftVisibleDuration = relativeTime;
|
||||
const rightVisibleDuration = element.duration - relativeTime;
|
||||
const rightVisibleDuration = subMediaTime({
|
||||
a: element.duration,
|
||||
b: relativeTime,
|
||||
});
|
||||
const retimeRef = isRetimableElement(element)
|
||||
? element.retime
|
||||
: undefined;
|
||||
const leftSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: leftVisibleDuration,
|
||||
retime: retimeRef,
|
||||
// Snap the source-side split point exactly once and derive the right
|
||||
// half from it. Independently rounding both spans (left and total)
|
||||
// would let a 1-tick rounding error desynchronise them, breaking the
|
||||
// invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`.
|
||||
// See the same discipline in `compute-resize.ts` (snap-once comment).
|
||||
const leftSourceSpan = roundMediaTime({
|
||||
time: getSourceSpanAtClipTime({
|
||||
clipTime: leftVisibleDuration,
|
||||
retime: retimeRef,
|
||||
}),
|
||||
});
|
||||
const totalSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: element.duration,
|
||||
retime: retimeRef,
|
||||
const totalSourceSpan = roundMediaTime({
|
||||
time: getSourceSpanAtClipTime({
|
||||
clipTime: element.duration,
|
||||
retime: retimeRef,
|
||||
}),
|
||||
});
|
||||
const rightSourceSpan = subMediaTime({
|
||||
a: totalSourceSpan,
|
||||
b: leftSourceSpan,
|
||||
});
|
||||
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations: element.animations,
|
||||
splitTime: relativeTime,
|
||||
@@ -100,19 +119,28 @@ export class SplitElementsCommand extends Command {
|
||||
});
|
||||
let splitResult: TimelineElement[];
|
||||
|
||||
const leftTrimEnd = addMediaTime({
|
||||
a: element.trimEnd,
|
||||
b: rightSourceSpan,
|
||||
});
|
||||
const rightTrimStart = addMediaTime({
|
||||
a: element.trimStart,
|
||||
b: leftSourceSpan,
|
||||
});
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
trimEnd: leftTrimEnd,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
} else if (this.retainSide === "right") {
|
||||
const newId = elementId(generateUUID());
|
||||
const newId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: newId,
|
||||
@@ -123,15 +151,14 @@ export class SplitElementsCommand extends Command {
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
trimStart: rightTrimStart,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = elementId(generateUUID());
|
||||
const secondElementId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: secondElementId,
|
||||
@@ -140,7 +167,7 @@ export class SplitElementsCommand extends Command {
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
trimEnd: leftTrimEnd,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
@@ -150,7 +177,7 @@ export class SplitElementsCommand extends Command {
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
trimStart: rightTrimStart,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
|
||||
@@ -10,12 +10,11 @@ import {
|
||||
resolveTrackPlacement,
|
||||
} from "@/timeline/placement";
|
||||
import { updateElementInSceneTracks } from "@/timeline/track-element-update";
|
||||
import {
|
||||
elementId,
|
||||
type SceneTracks,
|
||||
type TimelineElement,
|
||||
type VideoElement,
|
||||
} from "@/model";
|
||||
import type {
|
||||
SceneTracks,
|
||||
TimelineElement,
|
||||
VideoElement,
|
||||
} from "@/timeline/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
|
||||
export class ToggleSourceAudioSeparationCommand extends Command {
|
||||
@@ -76,7 +75,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
||||
...buildSeparatedAudioElement({
|
||||
sourceElement: videoElement,
|
||||
}),
|
||||
id: elementId(generateUUID()),
|
||||
id: generateUUID(),
|
||||
};
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks: this.savedState,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks, TimelineElement } from "@/model";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import {
|
||||
findTrackInSceneTracks,
|
||||
updateElementInSceneTracks,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks, TrackType } from "@/model";
|
||||
import type { SceneTracks, TrackType } from "@/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
|
||||
export class RemoveTrackCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class TracksSnapshotCommand extends Command {
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
|
||||
import {
|
||||
formatTimecode,
|
||||
type FrameRate,
|
||||
type TimeCodeFormat,
|
||||
} from "opencut-wasm";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
parseMediaTimecode,
|
||||
snapSeekMediaTime,
|
||||
type MediaTime,
|
||||
} from "@/wasm";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: number;
|
||||
duration: number;
|
||||
time: MediaTime;
|
||||
duration: MediaTime;
|
||||
format?: TimeCodeFormat;
|
||||
fps: FrameRate;
|
||||
onTimeChange?: ({ time }: { time: number }) => void;
|
||||
onTimeChange?: ({ time }: { time: MediaTime }) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -46,7 +55,11 @@ export function EditableTimecode({
|
||||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
|
||||
const parsedTime = parseMediaTimecode({
|
||||
timeCode: inputValue,
|
||||
format,
|
||||
fps,
|
||||
});
|
||||
|
||||
if (parsedTime == null) {
|
||||
setHasError(true);
|
||||
@@ -54,7 +67,7 @@ export function EditableTimecode({
|
||||
}
|
||||
|
||||
const clampedTime = duration
|
||||
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
|
||||
? snapSeekMediaTime({ time: parsedTime, duration, fps })
|
||||
: parsedTime;
|
||||
|
||||
onTimeChange?.({ time: clampedTime });
|
||||
|
||||
@@ -1,240 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
onAddToTimeline,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", handleDragOver);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
editor.timeline.dragSource.begin({
|
||||
dataTransfer: event.dataTransfer,
|
||||
dragData,
|
||||
});
|
||||
|
||||
setDragPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.({ e: event });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
editor.timeline.dragSource.end();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "w-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-auto w-full cursor-default flex-col gap-1 p-",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
title={name}
|
||||
>
|
||||
<span className="sr-only">{name}</span>
|
||||
<span aria-hidden="true">
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative w-full", containerClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="size-6 shrink-0 overflow-hidden rounded-sm">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-9999"
|
||||
style={{
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
|
||||
>
|
||||
<div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({
|
||||
className,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
tooltipText?: string;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipText) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
onAddToTimeline,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", handleDragOver);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
editor.timeline.dragSource.begin({
|
||||
dataTransfer: event.dataTransfer,
|
||||
dragData,
|
||||
});
|
||||
|
||||
setDragPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.({ e: event });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
editor.timeline.dragSource.end();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "w-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-auto w-full cursor-default flex-col gap-1 p-",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
title={name}
|
||||
>
|
||||
<span className="sr-only">{name}</span>
|
||||
<span aria-hidden="true">
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative w-full", containerClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="size-6 shrink-0 overflow-hidden rounded-sm">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-9999"
|
||||
style={{
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
|
||||
>
|
||||
<div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({
|
||||
className,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
tooltipText?: string;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipText) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useFileUpload } from "@/media/use-file-upload";
|
||||
import { invokeAction } from "@/actions";
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
type MediaViewMode,
|
||||
useAssetsPanelStore,
|
||||
} from "@/components/editor/panels/assets/assets-panel-store";
|
||||
import { MASKABLE_ELEMENT_TYPES } from "@/model";
|
||||
import { MASKABLE_ELEMENT_TYPES } from "@/timeline";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
@@ -255,11 +255,11 @@ function MediaAssetDraggable({
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
startTime: MediaTime;
|
||||
}) => {
|
||||
const duration =
|
||||
asset.duration != null
|
||||
? Math.round(asset.duration * TICKS_PER_SECOND)
|
||||
? mediaTimeFromSeconds({ seconds: asset.duration })
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: asset.id,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { NumberParamDefinition, ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition, NumberParamDefinition } from "@/params";
|
||||
import {
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { getElementLocalTime } from "@/animation";
|
||||
import { addMediaTime, mediaTime, type MediaTime } from "@/wasm";
|
||||
|
||||
export function useElementPlayhead({
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
startTime: MediaTime;
|
||||
duration: MediaTime;
|
||||
}) {
|
||||
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: startTime,
|
||||
elementDuration: duration,
|
||||
const localTime = mediaTime({
|
||||
ticks: getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: startTime,
|
||||
elementDuration: duration,
|
||||
}),
|
||||
});
|
||||
const isPlayheadWithinElementRange =
|
||||
playheadTime >= startTime &&
|
||||
playheadTime <= startTime + duration;
|
||||
playheadTime <= addMediaTime({ a: startTime, b: duration });
|
||||
|
||||
return { localTime, isPlayheadWithinElementRange };
|
||||
}
|
||||
|
||||
+96
-95
@@ -1,95 +1,96 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import type { TimelineElement } from "@/model";
|
||||
import { upsertElementKeyframe } from "@/timeline/animation-properties";
|
||||
|
||||
export function useKeyframedColorProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedColor,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedColor: string;
|
||||
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const onChange = ({ color }: { color: string }) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: color,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeEnd = () => editor.timeline.commitPreview();
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isKeyframedAtTime,
|
||||
hasAnimatedKeyframes,
|
||||
keyframeIdAtTime,
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
upsertElementKeyframe,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export function useKeyframedColorProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedColor,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedColor: string;
|
||||
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const onChange = ({ color }: { color: string }) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: color,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeEnd = () => editor.timeline.commitPreview();
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isKeyframedAtTime,
|
||||
hasAnimatedKeyframes,
|
||||
keyframeIdAtTime,
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
|
||||
+175
-174
@@ -1,174 +1,175 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/model/decorations/animations";
|
||||
import type { TimelineElement } from "@/model";
|
||||
import { upsertElementKeyframe } from "@/timeline/animation-properties";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import { usePropertyDraft } from "./use-property-draft";
|
||||
|
||||
export function useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue,
|
||||
parse,
|
||||
valueAtPlayhead,
|
||||
step,
|
||||
buildBaseUpdates,
|
||||
buildAdditionalKeyframes,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
displayValue: string;
|
||||
parse: (input: string) => number | null;
|
||||
valueAtPlayhead: number;
|
||||
step?: number;
|
||||
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
||||
buildAdditionalKeyframes?: ({
|
||||
value,
|
||||
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const snapValue = (value: number) =>
|
||||
step != null ? snapToStep({ value, step }) : value;
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue = ({ value }: { value: number }) => {
|
||||
const nextValue = snapValue(value);
|
||||
if (shouldUseAnimatedChannel) {
|
||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||
const updatedAnimations = [
|
||||
{ propertyPath, value: nextValue },
|
||||
...additionalKeyframes,
|
||||
].reduce(
|
||||
(currentAnimations, keyframe) =>
|
||||
upsertElementKeyframe({
|
||||
animations: currentAnimations,
|
||||
propertyPath: keyframe.propertyPath,
|
||||
time: localTime,
|
||||
value: keyframe.value,
|
||||
}),
|
||||
animations,
|
||||
);
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: { animations: updatedAnimations },
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value: nextValue }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const propertyDraft = usePropertyDraft({
|
||||
displayValue,
|
||||
parse: (input) => {
|
||||
const parsedValue = parse(input);
|
||||
return parsedValue === null ? null : snapValue(parsedValue);
|
||||
},
|
||||
onPreview: (value) => previewValue({ value }),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: snapValue(valueAtPlayhead),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commitValue = ({ value }: { value: number }) => {
|
||||
const nextValue = snapValue(value);
|
||||
if (shouldUseAnimatedChannel) {
|
||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
|
||||
...additionalKeyframes.map((keyframe) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath: keyframe.propertyPath,
|
||||
time: localTime,
|
||||
value: keyframe.value,
|
||||
})),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
patch: buildBaseUpdates({ value: nextValue }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...propertyDraft,
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
toggleKeyframe,
|
||||
commitValue,
|
||||
};
|
||||
}
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
upsertElementKeyframe,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import { usePropertyDraft } from "./use-property-draft";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export function useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue,
|
||||
parse,
|
||||
valueAtPlayhead,
|
||||
step,
|
||||
buildBaseUpdates,
|
||||
buildAdditionalKeyframes,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
displayValue: string;
|
||||
parse: (input: string) => number | null;
|
||||
valueAtPlayhead: number;
|
||||
step?: number;
|
||||
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
||||
buildAdditionalKeyframes?: ({
|
||||
value,
|
||||
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const snapValue = (value: number) =>
|
||||
step != null ? snapToStep({ value, step }) : value;
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue = ({ value }: { value: number }) => {
|
||||
const nextValue = snapValue(value);
|
||||
if (shouldUseAnimatedChannel) {
|
||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||
const updatedAnimations = [
|
||||
{ propertyPath, value: nextValue },
|
||||
...additionalKeyframes,
|
||||
].reduce(
|
||||
(currentAnimations, keyframe) =>
|
||||
upsertElementKeyframe({
|
||||
animations: currentAnimations,
|
||||
propertyPath: keyframe.propertyPath,
|
||||
time: localTime,
|
||||
value: keyframe.value,
|
||||
}),
|
||||
animations,
|
||||
);
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: { animations: updatedAnimations },
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value: nextValue }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const propertyDraft = usePropertyDraft({
|
||||
displayValue,
|
||||
parse: (input) => {
|
||||
const parsedValue = parse(input);
|
||||
return parsedValue === null ? null : snapValue(parsedValue);
|
||||
},
|
||||
onPreview: (value) => previewValue({ value }),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: snapValue(valueAtPlayhead),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commitValue = ({ value }: { value: number }) => {
|
||||
const nextValue = snapValue(value);
|
||||
if (shouldUseAnimatedChannel) {
|
||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
|
||||
...additionalKeyframes.map((keyframe) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath: keyframe.propertyPath,
|
||||
time: localTime,
|
||||
value: keyframe.value,
|
||||
})),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
patch: buildBaseUpdates({ value: nextValue }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...propertyDraft,
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
toggleKeyframe,
|
||||
commitValue,
|
||||
};
|
||||
}
|
||||
|
||||
+7
-4
@@ -7,14 +7,17 @@ import {
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
import type { ElementAnimations } from "@/model/decorations/animations";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
import type { ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { TimelineElement } from "@/model";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
@@ -39,7 +42,7 @@ export function useKeyframedParamProperty({
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
buildBaseUpdates: ({
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { AudioElement, EffectElement, GraphicElement, ImageElement, MaskableElement, RetimableElement, StickerElement, TextElement, TimelineElement, VideoElement, VisualElement } from "@/model";
|
||||
import type {
|
||||
EffectElement,
|
||||
GraphicElement,
|
||||
ImageElement,
|
||||
MaskableElement,
|
||||
RetimableElement,
|
||||
StickerElement,
|
||||
TextElement,
|
||||
VisualElement,
|
||||
VideoElement,
|
||||
AudioElement,
|
||||
TimelineElement,
|
||||
} from "@/timeline";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type CopyContext,
|
||||
type PasteContext,
|
||||
} from "@/clipboard";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class ClipboardManager {
|
||||
private entry: ClipboardEntry | null = null;
|
||||
@@ -34,7 +35,7 @@ export class ClipboardManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
paste({ time }: { time?: number } = {}): boolean {
|
||||
paste({ time }: { time?: MediaTime } = {}): boolean {
|
||||
if (!this.entry) {
|
||||
return false;
|
||||
}
|
||||
@@ -64,7 +65,7 @@ export class ClipboardManager {
|
||||
};
|
||||
}
|
||||
|
||||
private getPasteContext({ time }: { time?: number }): PasteContext {
|
||||
private getPasteContext({ time }: { time?: MediaTime }): PasteContext {
|
||||
return {
|
||||
editor: this.editor,
|
||||
selectedElements: this.editor.selection.getSelectedElements(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { EditorCore } from "@/core";
|
||||
import type { Command, CommandResult } from "@/commands";
|
||||
import type { EditorSelectionSnapshot } from "@/selection/editor-selection";
|
||||
import { applyRippleAdjustments, computeRippleAdjustments } from "@/ripple";
|
||||
import type { SceneTracks } from "@/model";
|
||||
import type { SceneTracks } from "@/timeline/types";
|
||||
|
||||
interface CommandHistoryEntry {
|
||||
command: Command;
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
clampMediaTime,
|
||||
type MediaTime,
|
||||
mediaTimeFromSeconds,
|
||||
roundFrameTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class PlaybackManager {
|
||||
private isPlaying = false;
|
||||
private currentTime = 0;
|
||||
private currentTime: MediaTime = ZERO_MEDIA_TIME;
|
||||
private volume = 1;
|
||||
private muted = false;
|
||||
private previousVolume = 1;
|
||||
private isScrubbing = false;
|
||||
private listeners = new Set<() => void>();
|
||||
private updateListeners = new Set<(time: number) => void>();
|
||||
private seekListeners = new Set<(time: number) => void>();
|
||||
private updateListeners = new Set<(time: MediaTime) => void>();
|
||||
private seekListeners = new Set<(time: MediaTime) => void>();
|
||||
private playbackTimer: number | null = null;
|
||||
private playbackStartWallTime = 0;
|
||||
private playbackStartTime = 0;
|
||||
private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
|
||||
private timelineScopeBound = false;
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
@@ -40,7 +46,7 @@ export class PlaybackManager {
|
||||
}
|
||||
|
||||
if (this.currentTime >= maxTime) {
|
||||
this.seek({ time: 0 });
|
||||
this.seek({ time: ZERO_MEDIA_TIME });
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
@@ -62,7 +68,7 @@ export class PlaybackManager {
|
||||
}
|
||||
}
|
||||
|
||||
seek({ time }: { time: number }): void {
|
||||
seek({ time }: { time: MediaTime }): void {
|
||||
this.currentTime = this.clampTimeToTimeline(time);
|
||||
if (this.isPlaying) {
|
||||
this.playbackStartWallTime = performance.now();
|
||||
@@ -109,7 +115,7 @@ export class PlaybackManager {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
getCurrentTime(): number {
|
||||
getCurrentTime(): MediaTime {
|
||||
return this.currentTime;
|
||||
}
|
||||
|
||||
@@ -135,12 +141,12 @@ export class PlaybackManager {
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
onUpdate(listener: (time: number) => void): () => void {
|
||||
onUpdate(listener: (time: MediaTime) => void): () => void {
|
||||
this.updateListeners.add(listener);
|
||||
return () => this.updateListeners.delete(listener);
|
||||
}
|
||||
|
||||
onSeek(listener: (time: number) => void): () => void {
|
||||
onSeek(listener: (time: MediaTime) => void): () => void {
|
||||
this.seekListeners.add(listener);
|
||||
return () => this.seekListeners.delete(listener);
|
||||
}
|
||||
@@ -165,6 +171,7 @@ export class PlaybackManager {
|
||||
|
||||
if (timeChanged) {
|
||||
this.notifySeek(this.currentTime);
|
||||
this.dispatchSeekEvent(this.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +181,18 @@ export class PlaybackManager {
|
||||
});
|
||||
}
|
||||
|
||||
private notifyUpdate(time: MediaTime): void {
|
||||
this.updateListeners.forEach((fn) => {
|
||||
fn(time);
|
||||
});
|
||||
}
|
||||
|
||||
private notifySeek(time: MediaTime): void {
|
||||
this.seekListeners.forEach((fn) => {
|
||||
fn(time);
|
||||
});
|
||||
}
|
||||
|
||||
private startTimer(): void {
|
||||
if (this.playbackTimer) {
|
||||
cancelAnimationFrame(this.playbackTimer);
|
||||
@@ -197,40 +216,42 @@ export class PlaybackManager {
|
||||
const fps = this.editor.project.getActive()?.settings.fps;
|
||||
const elapsedSeconds =
|
||||
(performance.now() - this.playbackStartWallTime) / 1000;
|
||||
const rawTime =
|
||||
this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
|
||||
const newTime = fps
|
||||
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
|
||||
: rawTime;
|
||||
const rawTime = addMediaTime({
|
||||
a: this.playbackStartTime,
|
||||
b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
|
||||
});
|
||||
const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime;
|
||||
const maxTime = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (newTime >= maxTime) {
|
||||
this.pause();
|
||||
this.currentTime = maxTime;
|
||||
this.notify();
|
||||
this.notifySeek(maxTime);
|
||||
return;
|
||||
this.notifySeek(maxTime);
|
||||
this.dispatchSeekEvent(maxTime);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTime = newTime;
|
||||
this.notifyUpdate(newTime);
|
||||
this.dispatchUpdateEvent(newTime);
|
||||
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
||||
};
|
||||
|
||||
private clampTimeToTimeline(time: number): number {
|
||||
private clampTimeToTimeline(time: MediaTime): MediaTime {
|
||||
const maxTime = this.editor.timeline.getTotalDuration();
|
||||
return Math.max(0, Math.min(maxTime, time));
|
||||
return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime });
|
||||
}
|
||||
|
||||
private notifySeek(time: number): void {
|
||||
for (const fn of this.seekListeners) {
|
||||
fn(time);
|
||||
private dispatchSeekEvent(time: MediaTime): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private notifyUpdate(time: number): void {
|
||||
for (const fn of this.updateListeners) {
|
||||
fn(time);
|
||||
private dispatchUpdateEvent(time: MediaTime): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { SceneTracks, TScene } from "@/model";
|
||||
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import {
|
||||
getMainScene,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ToggleBookmarkCommand,
|
||||
UpdateBookmarkCommand,
|
||||
} from "@/commands/scene";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class ScenesManager {
|
||||
private active: TScene | null = null;
|
||||
@@ -106,12 +107,12 @@ export class ScenesManager {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async toggleBookmark({ time }: { time: number }): Promise<void> {
|
||||
async toggleBookmark({ time }: { time: MediaTime }): Promise<void> {
|
||||
const command = new ToggleBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
isBookmarked({ time }: { time: number }): boolean {
|
||||
isBookmarked({ time }: { time: MediaTime }): boolean {
|
||||
const activeScene = this.getActiveScene();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
@@ -125,7 +126,7 @@ export class ScenesManager {
|
||||
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
|
||||
}
|
||||
|
||||
async removeBookmark({ time }: { time: number }): Promise<void> {
|
||||
async removeBookmark({ time }: { time: MediaTime }): Promise<void> {
|
||||
const command = new RemoveBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
@@ -134,8 +135,8 @@ export class ScenesManager {
|
||||
time,
|
||||
updates,
|
||||
}: {
|
||||
time: number;
|
||||
updates: Partial<{ note: string; color: string; duration: number }>;
|
||||
time: MediaTime;
|
||||
updates: Partial<Omit<Bookmark, "time">>;
|
||||
}): Promise<void> {
|
||||
const command = new UpdateBookmarkCommand(time, updates);
|
||||
this.editor.command.execute({ command });
|
||||
@@ -145,14 +146,14 @@ export class ScenesManager {
|
||||
fromTime,
|
||||
toTime,
|
||||
}: {
|
||||
fromTime: number;
|
||||
toTime: number;
|
||||
fromTime: MediaTime;
|
||||
toTime: MediaTime;
|
||||
}): Promise<void> {
|
||||
const command = new MoveBookmarkCommand(fromTime, toTime);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
getBookmarkAtTime({ time }: { time: number }) {
|
||||
getBookmarkAtTime({ time }: { time: MediaTime }) {
|
||||
const activeScene = this.active;
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
EditorSelectionSnapshot,
|
||||
SelectedMaskPointSelection,
|
||||
} from "@/selection/editor-selection";
|
||||
import type { ElementRef } from "@/model";
|
||||
import type { ElementRef } from "@/timeline/types";
|
||||
|
||||
export class SelectionManager {
|
||||
private selectedElements: ElementRef[] = [];
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { ElementBounds } from "@/preview/element-bounds";
|
||||
import type { ParamValues } from "@/model/decorations/param-values";
|
||||
import type { RetimeConfig, SceneTracks, TimelineElement, TimelineTrack, TrackType } from "@/model";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type {
|
||||
SceneTracks,
|
||||
TrackType,
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
RetimeConfig,
|
||||
} from "@/timeline";
|
||||
import { calculateTotalDuration } from "@/timeline";
|
||||
import { TimelineDragSource } from "@/timeline/drag-source";
|
||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||
import { lastFrameMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||
import {
|
||||
canElementBeHidden,
|
||||
canElementHaveAudio,
|
||||
} from "@/timeline/element-utils";
|
||||
import type { AnimationInterpolation, AnimationPath, AnimationValue, AnimationValueForPath } from "@/model/decorations/animations";
|
||||
import type { ScalarCurveKeyframePatch } from "@/animation/types";
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
AnimationValueForPath,
|
||||
ScalarCurveKeyframePatch,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveAnimationPathValueAtTime,
|
||||
} from "@/animation";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import { lastFrameTime } from "opencut-wasm";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
@@ -86,10 +97,10 @@ export class TimelineManager {
|
||||
pushHistory = true,
|
||||
}: {
|
||||
elementId: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
startTime?: number;
|
||||
duration?: number;
|
||||
trimStart: MediaTime;
|
||||
trimEnd: MediaTime;
|
||||
startTime?: MediaTime;
|
||||
duration?: MediaTime;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const trackId = this.findTrackIdForElement({ elementId });
|
||||
@@ -179,7 +190,7 @@ export class TimelineManager {
|
||||
retainSide = "both",
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}): { trackId: string; elementId: string }[] {
|
||||
const command = new SplitElementsCommand({
|
||||
@@ -191,20 +202,20 @@ export class TimelineManager {
|
||||
return command.getRightSideElements();
|
||||
}
|
||||
|
||||
getTotalDuration(): number {
|
||||
getTotalDuration(): MediaTime {
|
||||
const activeScene = this.editor.scenes.getActiveSceneOrNull();
|
||||
if (!activeScene) {
|
||||
return 0;
|
||||
return ZERO_MEDIA_TIME;
|
||||
}
|
||||
|
||||
return calculateTotalDuration({ tracks: activeScene.tracks });
|
||||
}
|
||||
|
||||
getLastFrameTime(): number {
|
||||
getLastFrameTime(): MediaTime {
|
||||
const duration = this.getTotalDuration();
|
||||
const fps = this.editor.project.getActive()?.settings.fps;
|
||||
if (!fps || duration <= 0) return duration;
|
||||
return lastFrameTime({ duration, rate: fps }) ?? duration;
|
||||
return lastFrameMediaTime({ duration, fps });
|
||||
}
|
||||
|
||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||
@@ -474,7 +485,7 @@ export class TimelineManager {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -591,7 +602,7 @@ export class TimelineManager {
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}): void {
|
||||
const command = new RetimeKeyframeCommand({
|
||||
trackId,
|
||||
@@ -649,7 +660,7 @@ export class TimelineManager {
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: number;
|
||||
interpolation?: "linear" | "hold";
|
||||
keyframeId?: string;
|
||||
@@ -698,9 +709,9 @@ export class TimelineManager {
|
||||
updates,
|
||||
}: {
|
||||
updates: readonly {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly updates: Partial<TimelineElement>;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<TimelineElement>;
|
||||
}[];
|
||||
}): void {
|
||||
let changedOverlayCount = 0;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ParamValues } from "@/model/decorations/param-values";
|
||||
import type { Effect } from "@/model/decorations/effect";
|
||||
import type { EffectElement, VisualElement } from "@/model";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { Effect } from "@/effects/types";
|
||||
import type { EffectElement, VisualElement } from "@/timeline";
|
||||
import { effectsRegistry } from "@/effects";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { EffectPass } from "@/model/decorations/effect";
|
||||
import type { EffectDefinition } from "@/effects/types";
|
||||
import type { EffectDefinition, EffectPass } from "@/effects/types";
|
||||
|
||||
export const GAUSSIAN_BLUR_SHADER = "gaussian-blur";
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { buildDefaultParamValues } from "@/params/registry";
|
||||
import { effectsRegistry } from "./registry";
|
||||
import type { ParamValues } from "@/model/decorations/param-values";
|
||||
import type { Effect, EffectPass } from "@/model/decorations/effect";
|
||||
import type { EffectDefinition } from "@/effects/types";
|
||||
import { VISUAL_ELEMENT_TYPES } from "@/model";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { Effect, EffectDefinition, EffectPass } from "@/effects/types";
|
||||
import { VISUAL_ELEMENT_TYPES } from "@/timeline";
|
||||
|
||||
export { effectsRegistry } from "./registry";
|
||||
export { registerDefaultEffects } from "./definitions";
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
|
||||
import type { EffectPass, EffectPassTemplate } from "@/model/decorations/effect";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
|
||||
export type {
|
||||
Effect,
|
||||
EffectPass,
|
||||
EffectPassTemplate,
|
||||
EffectUniformValue,
|
||||
} from "@/model/decorations/effect";
|
||||
export interface Effect {
|
||||
id: string;
|
||||
type: string;
|
||||
params: ParamValues;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type EffectUniformValue = number | number[];
|
||||
|
||||
export interface EffectPass {
|
||||
shader: string;
|
||||
uniforms: Record<string, EffectUniformValue>;
|
||||
}
|
||||
|
||||
export interface EffectPassTemplate {
|
||||
shader: string;
|
||||
uniforms(params: {
|
||||
effectParams: ParamValues;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Record<string, EffectUniformValue>;
|
||||
}
|
||||
|
||||
export interface EffectRendererConfig {
|
||||
passes: EffectPassTemplate[];
|
||||
|
||||
@@ -1,241 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import {
|
||||
useKeyframedParamProperty,
|
||||
type KeyframedParamPropertyResult,
|
||||
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
||||
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
|
||||
import type { GraphicElement } from "@/model";
|
||||
import {
|
||||
graphicsRegistry,
|
||||
registerDefaultGraphics,
|
||||
resolveGraphicElementParamsAtTime,
|
||||
} from "@/graphics";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
registerDefaultGraphics();
|
||||
|
||||
const DEFAULT_STROKE_WIDTH = 2;
|
||||
|
||||
export function GraphicTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const definition = graphicsRegistry.get(element.definitionId);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const { renderElement } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
|
||||
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Section collapsible sectionKey={`${element.id}:graphic`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{definition.name}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
{shapeParams.map((param) => (
|
||||
<AnimatedGraphicParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
trackId={trackId}
|
||||
element={liveElement}
|
||||
localTime={localTime}
|
||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||
resolvedParams={resolvedParams}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrokeSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const definition = graphicsRegistry.get(element.definitionId);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const { renderElement } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
const strokeParams = definition.params.filter((p) => p.group === "stroke");
|
||||
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
|
||||
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
|
||||
|
||||
const toggleStroke = () => {
|
||||
if (isStrokeEnabled) {
|
||||
lastStrokeWidth.current = Number(
|
||||
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
||||
);
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
patch: { params: { ...element.params, strokeWidth: 0 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
patch: {
|
||||
params: {
|
||||
...element.params,
|
||||
strokeWidth: lastStrokeWidth.current,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
collapsible
|
||||
defaultOpen={isStrokeEnabled}
|
||||
sectionKey={`${element.id}:stroke`}
|
||||
>
|
||||
<SectionHeader
|
||||
trailing={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleStroke();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SectionTitle>Stroke</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent
|
||||
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
|
||||
>
|
||||
<SectionFields>
|
||||
{strokeParams.map((param) => (
|
||||
<AnimatedGraphicParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
trackId={trackId}
|
||||
element={liveElement}
|
||||
localTime={localTime}
|
||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||
resolvedParams={resolvedParams}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedGraphicParamField({
|
||||
param,
|
||||
trackId,
|
||||
element,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedParams,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
element: GraphicElement;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedParams: ParamValues;
|
||||
}) {
|
||||
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
|
||||
{
|
||||
param,
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedParams[param.key] ?? param.default,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: value,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<PropertyParamField
|
||||
param={param}
|
||||
value={resolvedParams[param.key] ?? param.default}
|
||||
onPreview={animatedParam.onPreview}
|
||||
onCommit={animatedParam.onCommit}
|
||||
keyframe={{
|
||||
isActive: animatedParam.isKeyframedAtTime,
|
||||
isDisabled: !isPlayheadWithinElementRange,
|
||||
onToggle: animatedParam.toggleKeyframe,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import {
|
||||
useKeyframedParamProperty,
|
||||
type KeyframedParamPropertyResult,
|
||||
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import type { GraphicElement } from "@/timeline";
|
||||
import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
registerDefaultGraphics();
|
||||
|
||||
const DEFAULT_STROKE_WIDTH = 2;
|
||||
|
||||
export function GraphicTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const definition = graphicsRegistry.get(element.definitionId);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const { renderElement } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
|
||||
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Section collapsible sectionKey={`${element.id}:graphic`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{definition.name}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
{shapeParams.map((param) => (
|
||||
<AnimatedGraphicParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
trackId={trackId}
|
||||
element={liveElement}
|
||||
localTime={localTime}
|
||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||
resolvedParams={resolvedParams}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrokeSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const definition = graphicsRegistry.get(element.definitionId);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const { renderElement } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
const strokeParams = definition.params.filter((p) => p.group === "stroke");
|
||||
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
|
||||
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
|
||||
|
||||
const toggleStroke = () => {
|
||||
if (isStrokeEnabled) {
|
||||
lastStrokeWidth.current = Number(
|
||||
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
||||
);
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
patch: { params: { ...element.params, strokeWidth: 0 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
patch: {
|
||||
params: {
|
||||
...element.params,
|
||||
strokeWidth: lastStrokeWidth.current,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
collapsible
|
||||
defaultOpen={isStrokeEnabled}
|
||||
sectionKey={`${element.id}:stroke`}
|
||||
>
|
||||
<SectionHeader
|
||||
trailing={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleStroke();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SectionTitle>Stroke</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent
|
||||
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
|
||||
>
|
||||
<SectionFields>
|
||||
{strokeParams.map((param) => (
|
||||
<AnimatedGraphicParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
trackId={trackId}
|
||||
element={liveElement}
|
||||
localTime={localTime}
|
||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||
resolvedParams={resolvedParams}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedGraphicParamField({
|
||||
param,
|
||||
trackId,
|
||||
element,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedParams,
|
||||
}: {
|
||||
key?: string;
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
element: GraphicElement;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedParams: ParamValues;
|
||||
}) {
|
||||
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
|
||||
{
|
||||
param,
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedParams[param.key] ?? param.default,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: value,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<PropertyParamField
|
||||
param={param}
|
||||
value={resolvedParams[param.key] ?? param.default}
|
||||
onPreview={animatedParam.onPreview}
|
||||
onCommit={animatedParam.onCommit}
|
||||
keyframe={{
|
||||
isActive: animatedParam.isKeyframedAtTime,
|
||||
isDisabled: !isPlayheadWithinElementRange,
|
||||
onToggle: animatedParam.toggleKeyframe,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import { applyAlignedStroke } from "../stroke";
|
||||
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
|
||||
import type { GraphicDefinition } from "../types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import { applyAlignedStroke } from "../stroke";
|
||||
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
|
||||
import type { GraphicDefinition } from "../types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import { applyAlignedStroke } from "../stroke";
|
||||
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
|
||||
import type { GraphicDefinition } from "../types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
|
||||
export type GraphicStrokeAlign = "inside" | "center" | "outside";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import { applyAlignedStroke } from "../stroke";
|
||||
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
|
||||
import type { GraphicDefinition } from "../types";
|
||||
|
||||
+149
-149
@@ -1,149 +1,149 @@
|
||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||
import type { ElementAnimations } from "@/model/decorations/animations";
|
||||
import { buildDefaultParamValues } from "@/params/registry";
|
||||
import type { ParamValues } from "@/model/decorations/param-values";
|
||||
import { graphicsRegistry } from "./registry";
|
||||
import {
|
||||
registerDefaultGraphics,
|
||||
ellipseGraphicDefinition,
|
||||
polygonGraphicDefinition,
|
||||
rectangleGraphicDefinition,
|
||||
starGraphicDefinition,
|
||||
} from "./definitions";
|
||||
import {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
type GraphicInstance,
|
||||
type GraphicDefinition,
|
||||
} from "./types";
|
||||
|
||||
const graphicPreviewUrlCache = new Map<string, string>();
|
||||
|
||||
const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
|
||||
const FALLBACK_FILL_OPACITY = 0.08;
|
||||
const FALLBACK_MIN_FONT_SIZE = 12;
|
||||
const FALLBACK_FONT_SIZE_RATIO = 0.15;
|
||||
|
||||
function buildFallbackPreviewUrl({
|
||||
name,
|
||||
size,
|
||||
}: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
|
||||
</svg>
|
||||
`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function getGraphicDefinition({
|
||||
definitionId,
|
||||
}: {
|
||||
definitionId: string;
|
||||
}): GraphicDefinition {
|
||||
registerDefaultGraphics();
|
||||
return graphicsRegistry.get(definitionId);
|
||||
}
|
||||
|
||||
export function buildDefaultGraphicInstance({
|
||||
definitionId,
|
||||
}: {
|
||||
definitionId: string;
|
||||
}): GraphicInstance {
|
||||
const definition = getGraphicDefinition({ definitionId });
|
||||
return {
|
||||
definitionId,
|
||||
params: buildDefaultParamValues(definition.params),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGraphicParams(
|
||||
definition: GraphicDefinition,
|
||||
params?: ParamValues,
|
||||
): ParamValues {
|
||||
return {
|
||||
...buildDefaultParamValues(definition.params),
|
||||
...(params ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGraphicElementParamsAtTime({
|
||||
element,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
return resolveGraphicParamsAtTime({
|
||||
params: resolveGraphicParams(definition, element.params),
|
||||
definitions: definition.params,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGraphicPreviewUrl({
|
||||
definitionId,
|
||||
params,
|
||||
size = DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
}: {
|
||||
definitionId: string;
|
||||
params?: ParamValues;
|
||||
size?: number;
|
||||
}): string {
|
||||
const definition = getGraphicDefinition({ definitionId });
|
||||
const resolvedParams = resolveGraphicParams(definition, params);
|
||||
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
|
||||
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||
}
|
||||
|
||||
definition.render({
|
||||
ctx,
|
||||
params: resolvedParams,
|
||||
width: size,
|
||||
height: size,
|
||||
});
|
||||
|
||||
const previewUrl = canvas.toDataURL("image/png");
|
||||
graphicPreviewUrlCache.set(cacheKey, previewUrl);
|
||||
return previewUrl;
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
ellipseGraphicDefinition,
|
||||
graphicsRegistry,
|
||||
polygonGraphicDefinition,
|
||||
rectangleGraphicDefinition,
|
||||
registerDefaultGraphics,
|
||||
starGraphicDefinition,
|
||||
};
|
||||
export type {
|
||||
GraphicDefinition,
|
||||
GraphicInstance,
|
||||
GraphicRenderContext,
|
||||
} from "./types";
|
||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import { buildDefaultParamValues } from "@/params/registry";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { graphicsRegistry } from "./registry";
|
||||
import {
|
||||
registerDefaultGraphics,
|
||||
ellipseGraphicDefinition,
|
||||
polygonGraphicDefinition,
|
||||
rectangleGraphicDefinition,
|
||||
starGraphicDefinition,
|
||||
} from "./definitions";
|
||||
import {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
type GraphicInstance,
|
||||
type GraphicDefinition,
|
||||
} from "./types";
|
||||
|
||||
const graphicPreviewUrlCache = new Map<string, string>();
|
||||
|
||||
const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
|
||||
const FALLBACK_FILL_OPACITY = 0.08;
|
||||
const FALLBACK_MIN_FONT_SIZE = 12;
|
||||
const FALLBACK_FONT_SIZE_RATIO = 0.15;
|
||||
|
||||
function buildFallbackPreviewUrl({
|
||||
name,
|
||||
size,
|
||||
}: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
|
||||
</svg>
|
||||
`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function getGraphicDefinition({
|
||||
definitionId,
|
||||
}: {
|
||||
definitionId: string;
|
||||
}): GraphicDefinition {
|
||||
registerDefaultGraphics();
|
||||
return graphicsRegistry.get(definitionId);
|
||||
}
|
||||
|
||||
export function buildDefaultGraphicInstance({
|
||||
definitionId,
|
||||
}: {
|
||||
definitionId: string;
|
||||
}): GraphicInstance {
|
||||
const definition = getGraphicDefinition({ definitionId });
|
||||
return {
|
||||
definitionId,
|
||||
params: buildDefaultParamValues(definition.params),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGraphicParams(
|
||||
definition: GraphicDefinition,
|
||||
params?: ParamValues,
|
||||
): ParamValues {
|
||||
return {
|
||||
...buildDefaultParamValues(definition.params),
|
||||
...(params ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGraphicElementParamsAtTime({
|
||||
element,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
return resolveGraphicParamsAtTime({
|
||||
params: resolveGraphicParams(definition, element.params),
|
||||
definitions: definition.params,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGraphicPreviewUrl({
|
||||
definitionId,
|
||||
params,
|
||||
size = DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
}: {
|
||||
definitionId: string;
|
||||
params?: ParamValues;
|
||||
size?: number;
|
||||
}): string {
|
||||
const definition = getGraphicDefinition({ definitionId });
|
||||
const resolvedParams = resolveGraphicParams(definition, params);
|
||||
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
|
||||
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||
}
|
||||
|
||||
definition.render({
|
||||
ctx,
|
||||
params: resolvedParams,
|
||||
width: size,
|
||||
height: size,
|
||||
});
|
||||
|
||||
const previewUrl = canvas.toDataURL("image/png");
|
||||
graphicPreviewUrlCache.set(cacheKey, previewUrl);
|
||||
return previewUrl;
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
ellipseGraphicDefinition,
|
||||
graphicsRegistry,
|
||||
polygonGraphicDefinition,
|
||||
rectangleGraphicDefinition,
|
||||
registerDefaultGraphics,
|
||||
starGraphicDefinition,
|
||||
};
|
||||
export type {
|
||||
GraphicDefinition,
|
||||
GraphicInstance,
|
||||
GraphicRenderContext,
|
||||
} from "./types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
|
||||
export const DEFAULT_GRAPHIC_SOURCE_SIZE = 512;
|
||||
|
||||
|
||||
@@ -15,7 +15,12 @@ import { textMaskDefinition } from "@/masks/definitions/text";
|
||||
import { getMaskSnapGeometry } from "@/masks/geometry";
|
||||
import { snapMaskInteraction } from "@/masks/snap";
|
||||
import type { ElementBounds } from "@/preview/element-bounds";
|
||||
import type { CustomMaskParams, RectangleMaskParams, SplitMaskParams, TextMaskParams } from "@/model/decorations/mask";
|
||||
import type {
|
||||
CustomMaskParams,
|
||||
RectangleMaskParams,
|
||||
SplitMaskParams,
|
||||
TextMaskParams,
|
||||
} from "@/masks/types";
|
||||
|
||||
const bounds: ElementBounds = {
|
||||
cx: 200,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { MaskableElement } from "@/model";
|
||||
import type { Mask, MaskType, TextMask } from "@/model/decorations/mask";
|
||||
import type { NumberParamDefinition, SelectParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { MaskableElement } from "@/timeline";
|
||||
import type { Mask, MaskType, TextMask } from "@/masks/types";
|
||||
import type { NumberParamDefinition, SelectParamDefinition } from "@/params";
|
||||
import { masksRegistry, buildDefaultMaskInstance } from "@/masks";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { ElementBounds } from "@/preview/element-bounds";
|
||||
import type { CustomMaskPathPoint } from "@/model/decorations/mask";
|
||||
export type { CustomMaskPathPoint } from "@/model/decorations/mask";
|
||||
|
||||
export interface CustomMaskPathPoint {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
inX: number;
|
||||
inY: number;
|
||||
outX: number;
|
||||
outY: number;
|
||||
}
|
||||
|
||||
export type CustomMaskHandlePart = "anchor" | "in" | "out";
|
||||
|
||||
|
||||
@@ -3,9 +3,15 @@ import {
|
||||
MIN_MASK_DIMENSION,
|
||||
} from "@/masks/dimensions";
|
||||
import { computeFeatherUpdate } from "../param-update";
|
||||
import type { BaseMaskParams, RectangleMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefaultContext, MaskFeatures, MaskInteractionDefinition, MaskParamUpdateArgs } from "@/masks/types";
|
||||
import type { NumberParamDefinition, ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskDefaultContext,
|
||||
MaskFeatures,
|
||||
MaskInteractionDefinition,
|
||||
MaskParamUpdateArgs,
|
||||
RectangleMaskParams,
|
||||
} from "@/masks/types";
|
||||
import type { NumberParamDefinition, ParamDefinition } from "@/params";
|
||||
import {
|
||||
getBoxMaskHandlePositions,
|
||||
getBoxMaskOverlays,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { RectangleMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefaultContext, MaskDefinition } from "@/masks/types";
|
||||
import type {
|
||||
MaskDefaultContext,
|
||||
MaskDefinition,
|
||||
RectangleMaskParams,
|
||||
} from "@/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import type { ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import { PEN_CURSOR } from "@/preview/components/cursors";
|
||||
import type { ElementBounds } from "@/preview/element-bounds";
|
||||
import type { CustomMask, CustomMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefinition, MaskHandlePosition, MaskOverlay, MaskParamUpdateArgs } from "@/masks/types";
|
||||
import type {
|
||||
CustomMask,
|
||||
CustomMaskParams,
|
||||
MaskDefinition,
|
||||
MaskHandlePosition,
|
||||
MaskOverlay,
|
||||
MaskParamUpdateArgs,
|
||||
} from "@/masks/types";
|
||||
import {
|
||||
buildCustomMaskPath2D,
|
||||
buildCustomMaskSvgPath,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { RectangleMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefinition } from "@/masks/types";
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { RectangleMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefinition } from "@/masks/types";
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { RectangleMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefinition } from "@/masks/types";
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { BaseMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefinition } from "@/masks/types";
|
||||
import type { BaseMaskParams, MaskDefinition } from "@/masks/types";
|
||||
import { masksRegistry, type MaskIconProps } from "../registry";
|
||||
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
|
||||
import { customMaskDefinition } from "./custom";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { RectangleMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefinition } from "@/masks/types";
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { computeFeatherUpdate } from "../param-update";
|
||||
import type { SplitMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefinition, MaskParamUpdateArgs } from "@/masks/types";
|
||||
import type {
|
||||
MaskDefinition,
|
||||
MaskParamUpdateArgs,
|
||||
SplitMaskParams,
|
||||
} from "@/masks/types";
|
||||
import { halfPlaneSign, lineEdgeIntersection } from "../utils";
|
||||
import {
|
||||
getLineMaskHandlePositions,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { RectangleMaskParams } from "@/model/decorations/mask";
|
||||
import type { MaskDefinition } from "@/masks/types";
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user