Compare commits

..
Author SHA1 Message Date
Maze WintherandCursor 02d957582b refactor: move rendering primitives into model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 04:20:00 +02:00
Maze WintherandCursor ab982b888e refactor: extract decoration model types
Move animation, effect, mask, and param value shapes into model decorations so element data no longer depends on decoration domains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 03:58:21 +02:00
Maze WintherandCursor 6ac2116daa refactor: extract timeline model types
Move project entity shapes into the model layer so sibling domains no longer depend on timeline for core data types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 03:49:52 +02:00
Maze Winther 79cc7d2159 chore: bun.lock 2026-05-03 01:10:27 +02:00
Maze Winther 8ab3aeba7c docs: move NOTES.md file 2026-05-02 16:38:06 +02:00
275 changed files with 3194 additions and 4884 deletions
Generated
+1 -1
View File
@@ -3820,7 +3820,7 @@ dependencies = [
[[package]]
name = "opencut-wasm"
version = "0.2.10"
version = "0.2.9"
dependencies = [
"bridge",
"compositor",
+167 -5
View File
@@ -1,5 +1,167 @@
# 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).
<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)
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
+1 -1
View File
@@ -52,7 +52,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"opencut-wasm": "^0.2.10",
"opencut-wasm": "^0.2.9",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
+24 -68
View File
@@ -5,16 +5,7 @@ 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 {
addMediaTime,
maxMediaTime,
mediaTime,
mediaTimeFromSeconds,
minMediaTime,
subMediaTime,
TICKS_PER_SECOND,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { TICKS_PER_SECOND } from "@/wasm";
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
import { getElementsAtTime, hasMediaId } from "@/timeline";
import { cancelInteraction } from "@/editor/cancel-interaction";
@@ -92,7 +83,7 @@ export function useEditorActions() {
if (editor.playback.getIsPlaying()) {
editor.playback.toggle();
}
editor.playback.seek({ time: ZERO_MEDIA_TIME });
editor.playback.seek({ time: 0 });
},
undefined,
);
@@ -101,15 +92,11 @@ export function useEditorActions() {
"seek-forward",
(args) => {
const seconds = args?.seconds ?? 1;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
time: minMediaTime({
a: editor.timeline.getTotalDuration(),
b: addMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + seconds,
),
});
},
undefined,
@@ -119,15 +106,8 @@ export function useEditorActions() {
"seek-backward",
(args) => {
const seconds = args?.seconds ?? 1;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
time: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: subMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
});
},
undefined,
@@ -137,19 +117,14 @@ export function useEditorActions() {
"frame-step-forward",
() => {
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = mediaTime({
ticks: Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
const ticksPerFrame = 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,
}),
}),
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + ticksPerFrame,
),
});
},
undefined,
@@ -159,19 +134,11 @@ export function useEditorActions() {
"frame-step-backward",
() => {
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = mediaTime({
ticks: Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
const ticksPerFrame = Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
);
editor.playback.seek({
time: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: subMediaTime({
a: editor.playback.getCurrentTime(),
b: ticksPerFrame,
}),
}),
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
});
},
undefined,
@@ -181,15 +148,11 @@ export function useEditorActions() {
"jump-forward",
(args) => {
const seconds = args?.seconds ?? 5;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
time: minMediaTime({
a: editor.timeline.getTotalDuration(),
b: addMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + seconds,
),
});
},
undefined,
@@ -199,15 +162,8 @@ export function useEditorActions() {
"jump-backward",
(args) => {
const seconds = args?.seconds ?? 5;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
time: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: subMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
});
},
undefined,
@@ -216,7 +172,7 @@ export function useEditorActions() {
useActionHandler(
"goto-start",
() => {
editor.playback.seek({ time: ZERO_MEDIA_TIME });
editor.playback.seek({ time: 0 });
},
undefined,
);
+1 -1
View File
@@ -1,5 +1,5 @@
import { snapToStep } from "@/utils/math";
import type { ParamDefinition } from "@/params";
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { DynamicAnimationPathValue, NumericSpec } from "./types";
export function getAnimationParamValueKind({
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ScalarAnimationKey } from "@/animation/types";
import type { ScalarAnimationKey } from "@/model/decorations/animations";
const BEZIER_SOLVE_ITERATIONS = 20;
+1 -14
View File
@@ -1,18 +1,5 @@
import { converter, formatHex, formatHex8, parse } from "culori";
import type {
AnimationBindingComponent,
AnimationBindingOfKind,
AnimationBindingInstance,
AnimationBindingKind,
ColorAnimationBinding,
DiscreteAnimationBinding,
NumberAnimationBinding,
AnimationPath,
AnimationValue,
DiscreteValue,
Vector2AnimationBinding,
VectorValue,
} from "@/animation/types";
import type { AnimationBindingComponent, AnimationBindingInstance, AnimationBindingKind, AnimationBindingOfKind, AnimationPath, AnimationValue, ColorAnimationBinding, DiscreteAnimationBinding, DiscreteValue, NumberAnimationBinding, Vector2AnimationBinding, VectorValue } from "@/model/decorations/animations";
import { clamp } from "@/utils/math";
interface LinearRgba {
+3 -8
View File
@@ -2,12 +2,7 @@ import {
getDefaultLeftHandle,
getDefaultRightHandle,
} from "@/animation/bezier";
import type {
CurveHandle,
NormalizedCubicBezier,
ScalarAnimationKey,
} from "@/animation/types";
import { roundMediaTime } from "@/wasm";
import type { CurveHandle, NormalizedCubicBezier, ScalarAnimationKey } from "@/model/decorations/animations";
const VALUE_EPSILON = 1e-6;
@@ -85,11 +80,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
return {
rightHandle: {
dt: roundMediaTime({ time: spanTime * x1 }),
dt: spanTime * x1,
dv: effectiveSpanValue * y1,
},
leftHandle: {
dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
dt: spanTime * (x2 - 1),
dv: effectiveSpanValue * (y2 - 1),
},
};
@@ -1,8 +1,5 @@
import type {
ElementAnimations,
EffectParamPath,
} from "@/animation/types";
import type { ParamValues } from "@/params";
import type { EffectParamPath, ElementAnimations } from "@/model/decorations/animations";
import type { ParamValues } from "@/model/decorations/param-values";
import { removeElementKeyframe } from "./keyframes";
import { resolveAnimationPathValueAtTime } from "./resolve";
+2 -8
View File
@@ -1,11 +1,5 @@
import type {
AnimationBindingInstance,
AnimationPath,
ElementAnimations,
ScalarAnimationChannel,
ScalarGraphChannel,
ScalarGraphKeyframeContext,
} from "@/animation/types";
import type { AnimationBindingInstance, AnimationPath, ElementAnimations, ScalarAnimationChannel } from "@/model/decorations/animations";
import type { ScalarGraphChannel, ScalarGraphKeyframeContext } from "@/animation/types";
export interface EditableScalarChannels {
binding: AnimationBindingInstance;
@@ -1,8 +1,5 @@
import type {
ElementAnimations,
GraphicParamPath,
} from "@/animation/types";
import type { ParamDefinition, ParamValues } from "@/params";
import type { ElementAnimations, GraphicParamPath } from "@/model/decorations/animations";
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
import { resolveAnimationPathValueAtTime } from "./resolve";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
-13
View File
@@ -16,7 +16,6 @@ export {
setChannel,
splitAnimationsAtTime,
updateScalarKeyframeCurve,
upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
@@ -72,16 +71,4 @@ export {
export {
isAnimationPath,
isAnimationPropertyPath,
} from "./path";
export {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getDefaultInterpolationForProperty,
getElementBaseValueForProperty,
supportsAnimationProperty,
type AnimationPropertyDefinition,
type NumericSpec,
withElementBaseValueForProperty,
} from "./property-registry";
+5 -23
View File
@@ -1,14 +1,4 @@
import type {
AnimationChannel,
AnimationInterpolation,
AnimationValue,
DiscreteAnimationChannel,
DiscreteValue,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
} from "@/animation/types";
import { mediaTime } from "@/wasm";
import type { AnimationChannel, AnimationInterpolation, AnimationValue, DiscreteAnimationChannel, DiscreteValue, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType } from "@/model/decorations/animations";
import {
getBezierPoint,
getDefaultLeftHandle,
@@ -64,13 +54,9 @@ function normalizeRightHandle({
return undefined;
}
const span = mediaTime({
ticks: Math.max(1, rightKey.time - leftKey.time),
});
const span = Math.max(1, rightKey.time - leftKey.time);
return {
dt: mediaTime({
ticks: Math.min(span, Math.max(0, handle.dt)),
}),
dt: Math.min(span, Math.max(0, handle.dt)),
dv: handle.dv,
};
}
@@ -88,13 +74,9 @@ function normalizeLeftHandle({
return undefined;
}
const span = mediaTime({
ticks: Math.max(1, rightKey.time - leftKey.time),
});
const span = Math.max(1, rightKey.time - leftKey.time);
return {
dt: mediaTime({
ticks: Math.max(-span, Math.min(0, handle.dt)),
}),
dt: Math.max(-span, Math.min(0, handle.dt)),
dv: handle.dv,
};
}
+2 -7
View File
@@ -1,10 +1,5 @@
import type {
AnimationBindingInstance,
AnimationChannel,
AnimationPath,
ElementAnimations,
ElementKeyframe,
} from "@/animation/types";
import type { AnimationBindingInstance, AnimationChannel, AnimationPath, ElementAnimations } from "@/model/decorations/animations";
import type { ElementKeyframe } from "@/animation/types";
import {
type AnimationComponentValue,
composeAnimationValue,
+26 -97
View File
@@ -1,19 +1,5 @@
import type {
AnimationBindingInstance,
AnimationBindingKind,
AnimationChannel,
AnimationInterpolation,
AnimationPath,
AnimationPropertyPath,
AnimationValue,
DiscreteAnimationChannel,
DiscreteAnimationKey,
ElementAnimations,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/animation/types";
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 {
cloneAnimationBinding,
createAnimationBinding,
@@ -30,16 +16,6 @@ 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({
@@ -177,7 +153,7 @@ function createScalarKey({
previousKey,
}: {
id: string;
time: MediaTime;
time: number;
value: number;
interpolation?: AnimationInterpolation;
previousKey?: ScalarAnimationKey;
@@ -201,7 +177,7 @@ function createDiscreteKey({
value,
}: {
id: string;
time: MediaTime;
time: number;
value: string | boolean;
}): DiscreteAnimationKey {
return {
@@ -247,7 +223,7 @@ function getTargetKeyMetadata({
keyframeId,
}: {
channel: AnimationChannel | undefined;
time: MediaTime;
time: number;
keyframeId?: string;
}) {
const normalizedChannel =
@@ -286,7 +262,7 @@ function upsertDiscreteChannelKey({
keyframeId,
}: {
channel: DiscreteAnimationChannel | undefined;
time: MediaTime;
time: number;
value: string | boolean;
keyframeId?: string;
}): DiscreteAnimationChannel {
@@ -343,7 +319,7 @@ function upsertScalarChannelKey({
keyframeId,
}: {
channel: ScalarAnimationChannel | undefined;
time: MediaTime;
time: number;
value: number;
interpolation?: AnimationInterpolation;
defaultInterpolation?: AnimationInterpolation;
@@ -448,7 +424,7 @@ export function upsertPathKeyframe({
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
time: MediaTime;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -533,47 +509,6 @@ 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,
@@ -582,7 +517,7 @@ export function upsertKeyframe({
keyframeId,
}: {
channel: AnimationChannel | undefined;
time: MediaTime;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -648,7 +583,7 @@ export function retimeKeyframe({
}: {
channel: AnimationChannel | undefined;
keyframeId: string;
time: MediaTime;
time: number;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
@@ -893,7 +828,7 @@ export function clampAnimationsToDuration({
duration,
}: {
animations: ElementAnimations | undefined;
duration: MediaTime;
duration: number;
}): ElementAnimations | undefined {
if (!animations || duration <= 0) {
return undefined;
@@ -929,7 +864,7 @@ function splitDiscreteChannelAtTime({
shouldIncludeSplitBoundary,
}: {
channel: DiscreteAnimationChannel | undefined;
splitTime: MediaTime;
splitTime: number;
leftBoundaryId: string;
rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean;
@@ -945,10 +880,7 @@ function splitDiscreteChannelAtTime({
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
let rightKeys = normalizedChannel.keys
.filter((key) => key.time >= splitTime)
.map((key) => ({
...key,
time: subMediaTime({ a: key.time, b: splitTime }),
}));
.map((key) => ({ ...key, time: key.time - splitTime }));
if (shouldIncludeSplitBoundary) {
const hasBoundaryOnLeft = leftKeys.some((key) =>
@@ -976,7 +908,7 @@ function splitDiscreteChannelAtTime({
rightKeys = [
createDiscreteKey({
id: rightBoundaryId,
time: ZERO_MEDIA_TIME,
time: 0,
value: boundaryValue as string | boolean,
}),
...rightKeys,
@@ -1002,7 +934,7 @@ function splitScalarChannelAtTime({
shouldIncludeSplitBoundary,
}: {
channel: ScalarAnimationChannel | undefined;
splitTime: MediaTime;
splitTime: number;
leftBoundaryId: string;
rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean;
@@ -1018,10 +950,7 @@ function splitScalarChannelAtTime({
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
let rightKeys = normalizedChannel.keys
.filter((key) => key.time >= splitTime)
.map((key) => ({
...key,
time: subMediaTime({ a: key.time, b: splitTime }),
}));
.map((key) => ({ ...key, time: key.time - splitTime }));
const hasBoundaryOnLeft = leftKeys.some((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
@@ -1101,7 +1030,7 @@ function splitScalarChannelAtTime({
{
...leftKey,
rightHandle: {
dt: roundMediaTime({ time: q0.x - p0.x }),
dt: q0.x - p0.x,
dv: q0.y - p0.y,
},
},
@@ -1110,7 +1039,7 @@ function splitScalarChannelAtTime({
time: splitTime,
value: boundaryValue,
leftHandle: {
dt: roundMediaTime({ time: r0.x - splitPoint.x }),
dt: r0.x - splitPoint.x,
dv: r0.y - splitPoint.y,
},
segmentToNext: leftKey.segmentToNext,
@@ -1120,10 +1049,10 @@ function splitScalarChannelAtTime({
rightKeys = [
{
id: rightBoundaryId,
time: ZERO_MEDIA_TIME,
time: 0,
value: boundaryValue,
rightHandle: {
dt: roundMediaTime({ time: r1.x - splitPoint.x }),
dt: r1.x - splitPoint.x,
dv: r1.y - splitPoint.y,
},
segmentToNext: "bezier",
@@ -1131,9 +1060,9 @@ function splitScalarChannelAtTime({
},
{
...rightKey,
time: subMediaTime({ a: rightKey.time, b: splitTime }),
time: rightKey.time - splitTime,
leftHandle: {
dt: roundMediaTime({ time: q2.x - p3.x }),
dt: q2.x - p3.x,
dv: q2.y - p3.y,
},
},
@@ -1141,7 +1070,7 @@ function splitScalarChannelAtTime({
.filter((key) => key.time > rightKey.time)
.map((key) => ({
...key,
time: subMediaTime({ a: key.time, b: splitTime }),
time: key.time - splitTime,
})),
];
} else {
@@ -1157,7 +1086,7 @@ function splitScalarChannelAtTime({
rightKeys = [
createScalarKey({
id: rightBoundaryId,
time: ZERO_MEDIA_TIME,
time: 0,
value: boundaryValue,
interpolation: getScalarSegmentInterpolation({
segment: leftKey.segmentToNext,
@@ -1213,7 +1142,7 @@ export function splitAnimationsAtTime({
shouldIncludeSplitBoundary = true,
}: {
animations: ElementAnimations | undefined;
splitTime: MediaTime;
splitTime: number;
shouldIncludeSplitBoundary?: boolean;
}): {
leftAnimations: ElementAnimations | undefined;
@@ -1329,7 +1258,7 @@ export function retimeElementKeyframe({
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
time: MediaTime;
time: number;
}): ElementAnimations | undefined {
const binding = getBinding({ animations, propertyPath });
if (!binding) {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
import type { AnimationPath, AnimationPropertyPath } from "@/model/decorations/animations";
import { ANIMATION_PROPERTY_PATHS } from "./types";
import { isEffectParamPath } from "./effect-param-channel";
import { isGraphicParamPath } from "./graphic-param-channel";
+2 -6
View File
@@ -1,9 +1,5 @@
import type {
AnimationPropertyGroup,
AnimationPropertyPath,
ElementAnimations,
} from "@/animation/types";
import { ANIMATION_PROPERTY_GROUPS } from "@/animation/types";
import type { AnimationPropertyGroup, AnimationPropertyPath, ElementAnimations } from "@/model/decorations/animations";
import { ANIMATION_PROPERTY_GROUPS } from "@/model/decorations/animations";
import { getKeyframeAtTime } from "./keyframe-query";
export interface GroupKeyframeRef {
-390
View File
@@ -1,390 +0,0 @@
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 -5
View File
@@ -1,8 +1,4 @@
import type {
AnimationPath,
AnimationValueForPath,
ElementAnimations,
} from "@/animation/types";
import type { AnimationPath, AnimationValueForPath, ElementAnimations } from "@/model/decorations/animations";
import {
type AnimationComponentValue,
composeAnimationValue,
+58 -185
View File
@@ -1,189 +1,62 @@
import type { ParamValues } from "@/params";
import type { MediaTime } from "@/wasm";
import type {
AnimationInterpolation,
AnimationPath,
AnimationValue,
CurveHandle,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
TangentMode,
} 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 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 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 {
ANIMATION_PROPERTY_GROUPS,
ANIMATION_PROPERTY_PATHS,
} from "@/model/decorations/animations";
export interface ScalarGraphChannelTarget {
propertyPath: AnimationPath;
@@ -216,7 +89,7 @@ export interface ScalarCurveKeyframePatch {
export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
time: MediaTime;
time: number;
value: AnimationValue;
interpolation: AnimationInterpolation;
}
+5 -7
View File
@@ -1,19 +1,18 @@
import { getKeyframeById } from "@/animation";
import type { SelectedKeyframeRef } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
import { elementId, type ElementRef, type TimelineElement } from "@/model";
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;
@@ -21,7 +20,7 @@ function resolveSingleSourceElement({
const sourceElement = {
trackId: firstKeyframe.trackId,
elementId: firstKeyframe.elementId,
elementId: elementId(firstKeyframe.elementId),
};
const isSingleSource = selectedKeyframes.every(
(keyframe) =>
@@ -81,7 +80,7 @@ function buildClipboardItem({
element: TimelineElement;
propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string;
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
const keyframe = getKeyframeById({
animations: element.animations,
propertyPath,
@@ -137,11 +136,10 @@ 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: subMediaTime({ a: time, b: minTimeMedia }),
timeOffset: time - minTime,
}))
.sort(
(left, right) =>
+5 -15
View File
@@ -1,18 +1,8 @@
import type { EditorCore } from "@/core";
import type {
AnimationInterpolation,
AnimationPath,
AnimationValue,
ScalarCurveKeyframePatch,
SelectedKeyframeRef,
} from "@/animation/types";
import type { AnimationInterpolation, AnimationPath, AnimationValue } from "@/model/decorations/animations";
import type { ScalarCurveKeyframePatch, SelectedKeyframeRef } from "@/animation/types";
import type { Command } from "@/commands/base-command";
import type {
CreateTimelineElement,
ElementRef,
TrackType,
} from "@/timeline";
import type { MediaTime } from "@/wasm";
import type { CreateTimelineElement, ElementRef, TrackType } from "@/model";
export interface ElementClipboardItem {
trackId: string;
@@ -27,7 +17,7 @@ export interface KeyframeClipboardCurvePatch {
export interface KeyframeClipboardItem {
propertyPath: AnimationPath;
timeOffset: MediaTime;
timeOffset: number;
value: AnimationValue;
interpolation: AnimationInterpolation;
curvePatches: KeyframeClipboardCurvePatch[];
@@ -62,7 +52,7 @@ export interface PasteContext {
editor: EditorCore;
selectedElements: ElementRef[];
selectedKeyframes: SelectedKeyframeRef[];
time: MediaTime;
time: number;
}
export interface ClipboardHandler<TType extends ClipboardEntryType> {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { EditorSelectionPatch } from "@/selection/editor-selection";
import type { ElementRef } from "@/timeline/types";
import type { ElementRef } from "@/model";
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 "@/timeline";
import type { SceneTracks } from "@/model";
export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/timeline";
import type { TScene } from "@/model";
import { buildDefaultScene } from "@/timeline/scenes";
export class CreateSceneCommand extends Command {
+1 -1
View File
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/timeline";
import type { TScene } from "@/model";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/timeline/scenes";
export class DeleteSceneCommand extends Command {
+3 -4
View File
@@ -1,16 +1,15 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/timeline";
import type { TScene } from "@/model";
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: MediaTime,
private toTime: MediaTime,
private fromTime: number,
private toTime: number,
) {
super();
}
@@ -1,18 +1,17 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/timeline";
import type { TScene } from "@/model";
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: MediaTime = 0 as MediaTime;
private frameTime: number = 0;
constructor(private time: MediaTime) {
constructor(private time: number) {
super();
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/timeline";
import type { TScene } from "@/model";
import { updateSceneInArray } from "@/timeline/scenes";
export class RenameSceneCommand extends Command {
@@ -1,18 +1,17 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/timeline";
import type { TScene } from "@/model";
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: MediaTime = 0 as MediaTime;
private frameTime: number = 0;
constructor(private time: MediaTime) {
constructor(private time: number) {
super();
}
@@ -1,18 +1,17 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { Bookmark, TScene } from "@/timeline";
import type { Bookmark, TScene } from "@/model";
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: MediaTime,
private time: number,
private updates: Partial<Omit<Bookmark, "time">>,
) {
super();
@@ -6,17 +6,10 @@ import {
} from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import type { KeyframeClipboardItem } from "@/clipboard";
import type { SceneTracks, TimelineElement } from "@/timeline";
import type { SceneTracks, TimelineElement } from "@/model";
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,
@@ -24,7 +17,7 @@ function pasteKeyframesIntoElement({
clipboardItems,
}: {
element: TimelineElement;
time: MediaTime;
time: number;
clipboardItems: KeyframeClipboardItem[];
}): TimelineElement {
let nextElement = element;
@@ -38,13 +31,10 @@ function pasteKeyframesIntoElement({
continue;
}
const keyframeTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({
a: addMediaTime({ a: time, b: item.timeOffset }),
b: nextElement.duration,
}),
});
const keyframeTime = Math.max(
0,
Math.min(time + item.timeOffset, nextElement.duration),
);
const nextAnimations = upsertPathKeyframe({
animations: nextElement.animations,
propertyPath: item.propertyPath,
@@ -89,7 +79,7 @@ export class PasteKeyframesCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly time: MediaTime;
private readonly time: number;
private readonly clipboardItems: KeyframeClipboardItem[];
constructor({
@@ -100,7 +90,7 @@ export class PasteKeyframesCommand extends Command {
}: {
trackId: string;
elementId: string;
time: MediaTime;
time: number;
clipboardItems: KeyframeClipboardItem[];
}) {
super();
@@ -4,7 +4,7 @@ import {
type CommandResult,
} from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { SceneTracks, TimelineElement } from "@/timeline";
import type { ElementRef, SceneTracks, TimelineElement } from "@/model";
import type { ElementClipboardItem } from "@/clipboard";
import { generateUUID } from "@/utils/id";
import {
@@ -13,25 +13,18 @@ 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: { trackId: string; elementId: string }[] = [];
private readonly time: MediaTime;
private pastedElements: ElementRef[] = [];
private readonly time: number;
private readonly clipboardItems: ElementClipboardItem[];
constructor({
time,
clipboardItems,
}: {
time: MediaTime;
time: number;
clipboardItems: ElementClipboardItem[];
}) {
super();
@@ -46,12 +39,8 @@ export class PasteCommand extends Command {
this.savedState = editor.scenes.getActiveScene().tracks;
this.pastedElements = [];
const minStart = this.clipboardItems.reduce(
(earliestStartTime, item) =>
item.element.startTime < earliestStartTime
? item.element.startTime
: earliestStartTime,
this.clipboardItems[0].element.startTime,
const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime),
);
let updatedTracks = this.savedState;
@@ -108,18 +97,12 @@ export class PasteCommand extends Command {
targetTrackId: targetTrack.id,
requestedStartTime: earliestElement.startTime,
});
const delta = subMediaTime({
a: adjustedEarliestStartTime,
b: earliestElement.startTime,
});
const delta = adjustedEarliestStartTime - earliestElement.startTime;
if (delta !== ZERO_MEDIA_TIME) {
if (delta !== 0) {
elementsForPlacement = elementsToAdd.map((element) => ({
...element,
startTime: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: addMediaTime({ a: element.startTime, b: delta }),
}),
startTime: Math.max(0, element.startTime + delta),
}));
}
}
@@ -159,7 +142,7 @@ export class PasteCommand extends Command {
}
}
getPastedElements(): { trackId: string; elementId: string }[] {
getPastedElements(): ElementRef[] {
return this.pastedElements;
}
}
@@ -185,20 +168,14 @@ function buildPastedElements({
time,
}: {
items: ElementClipboardItem[];
minStart: MediaTime;
time: MediaTime;
minStart: number;
time: number;
}): TimelineElement[] {
const elementsToAdd: TimelineElement[] = [];
for (const item of items) {
const relativeOffset = subMediaTime({
a: item.element.startTime,
b: minStart,
});
const startTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: addMediaTime({ a: time, b: relativeOffset }),
});
const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, time + relativeOffset);
const newElementId = generateUUID();
elementsToAdd.push({
@@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/commands/base-command";
import type { SceneTracks } from "@/timeline";
import type { SceneTracks } from "@/model";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/timeline";
import type { TimelineTrack } from "@/model";
function removeTrackElements<TTrack extends TimelineTrack>({
track,
@@ -3,19 +3,24 @@ import {
createElementSelectionResult,
type CommandResult,
} from "@/commands/base-command";
import type { SceneTracks, TimelineElement } from "@/timeline";
import {
elementId,
type ElementId,
type ElementRef,
type SceneTracks,
type TimelineElement,
} from "@/model";
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: { trackId: string; elementId: string }[] = [];
private duplicatedElements: ElementRef[] = [];
private savedState: SceneTracks | null = null;
private elements: DuplicateElementsParams["elements"];
@@ -54,7 +59,7 @@ export class DuplicateElementsCommand extends Command {
continue;
}
const newId = generateUUID();
const newId = elementId(generateUUID());
newTrackElements.push(
buildDuplicateElement({
element,
@@ -108,7 +113,7 @@ export class DuplicateElementsCommand extends Command {
}
}
getDuplicatedElements(): { trackId: string; elementId: string }[] {
getDuplicatedElements(): ElementRef[] {
return this.duplicatedElements;
}
}
@@ -119,8 +124,8 @@ function buildDuplicateElement({
startTime,
}: {
element: TimelineElement;
id: string;
startTime: MediaTime;
id: ElementId;
startTime: number;
}): 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 "@/timeline";
import type { SceneTracks, VisualElement } from "@/model";
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 "@/timeline";
import type { SceneTracks, VisualElement } from "@/model";
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 "@/timeline";
import type { SceneTracks, VisualElement } from "@/model";
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 "@/timeline";
import type { SceneTracks, VisualElement } from "@/model";
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 "@/params";
import type { SceneTracks, VisualElement } from "@/timeline";
import type { ParamValues } from "@/model/decorations/param-values";
import type { SceneTracks, VisualElement } from "@/model";
function updateEffectParamsOnElement({
element,
@@ -1,11 +1,13 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type {
CreateTimelineElement,
SceneTracks,
TimelineElement,
TrackType,
} from "@/timeline";
import {
elementId,
type CreateTimelineElement,
type ElementId,
type SceneTracks,
type TimelineElement,
type TrackType,
} from "@/model";
import { generateUUID } from "@/utils/id";
import { requiresMediaId } from "@/timeline/element-utils";
import type { MediaAsset } from "@/media/types";
@@ -18,7 +20,6 @@ import {
resolveTrackPlacement,
validateElementTrackCompatibility,
} from "@/timeline/placement";
import { roundMediaTime } from "@/wasm";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -30,13 +31,13 @@ export interface InsertElementParams {
}
export class InsertElementCommand extends Command {
private elementId: string;
private elementId: ElementId;
private savedState: SceneTracks | null = null;
private targetTrackId: string | null = null;
constructor({ element, placement }: InsertElementParams) {
super();
this.elementId = generateUUID();
this.elementId = elementId(generateUUID());
this.element = element;
this.placement = placement;
}
@@ -267,12 +268,7 @@ export class InsertElementCommand extends Command {
placementResult.kind === "existingTrack"
? {
...element,
startTime:
placementResult.adjustedStartTime !== undefined
? roundMediaTime({
time: placementResult.adjustedStartTime,
})
: element.startTime,
startTime: 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 "@/timeline";
import type { SceneTracks } from "@/model";
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 "@/animation/types";
import type { SceneTracks, TimelineElement } from "@/timeline";
import type { AnimationPath, AnimationValue } from "@/model/decorations/animations";
import type { SceneTracks, TimelineElement } from "@/model";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
function removeKeyframeAndPersist({
@@ -2,14 +2,8 @@ import { EditorCore } from "@/core";
import { retimeElementKeyframe } from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
import type { AnimationPath } from "@/model/decorations/animations";
import type { SceneTracks } from "@/model";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
export class RetimeKeyframeCommand extends Command {
@@ -18,7 +12,7 @@ export class RetimeKeyframeCommand extends Command {
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
private readonly nextTime: MediaTime;
private readonly nextTime: number;
constructor({
trackId,
@@ -31,7 +25,7 @@ export class RetimeKeyframeCommand extends Command {
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
nextTime: MediaTime;
nextTime: number;
}) {
super();
this.trackId = trackId;
@@ -54,10 +48,7 @@ export class RetimeKeyframeCommand extends Command {
return element;
}
const boundedTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({ a: this.nextTime, b: element.duration }),
});
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
return {
...element,
animations: retimeElementKeyframe({
@@ -5,11 +5,9 @@ import {
import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type {
AnimationPath,
ScalarCurveKeyframePatch,
} from "@/animation/types";
import type { SceneTracks } from "@/timeline";
import type { AnimationPath } from "@/model/decorations/animations";
import type { ScalarCurveKeyframePatch } from "@/animation/types";
import type { SceneTracks } from "@/model";
export class UpdateScalarKeyframeCurveCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -7,14 +7,8 @@ import {
import { updateElementInSceneTracks } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { AnimationInterpolation } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
import type { AnimationInterpolation } from "@/model/decorations/animations";
import type { SceneTracks } from "@/model";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -22,7 +16,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
private readonly elementId: string;
private readonly effectId: string;
private readonly paramKey: string;
private readonly time: MediaTime;
private readonly time: number;
private readonly value: number | string | boolean;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@@ -41,7 +35,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: string;
effectId: string;
paramKey: string;
time: MediaTime;
time: number;
value: number | string | boolean;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -67,10 +61,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
const boundedTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({ a: this.time, b: element.duration }),
});
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
const propertyPath = buildEffectParamPath({
effectId: this.effectId,
paramKey: this.paramKey,
@@ -2,26 +2,16 @@ import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import { upsertPathKeyframe } from "@/animation";
import { updateElementInSceneTracks } from "@/timeline";
import type { SceneTracks } from "@/timeline";
import type { SceneTracks } from "@/model";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type {
AnimationPath,
AnimationInterpolation,
AnimationValue,
} from "@/animation/types";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
import type { AnimationInterpolation, AnimationPath, AnimationValue } from "@/model/decorations/animations";
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: MediaTime;
private readonly time: number;
private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@@ -38,7 +28,7 @@ export class UpsertKeyframeCommand extends Command {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
time: MediaTime;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -70,10 +60,7 @@ export class UpsertKeyframeCommand extends Command {
return element;
}
const boundedTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({ a: this.time, b: element.duration }),
});
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
return {
...element,
animations: upsertPathKeyframe({
@@ -4,9 +4,9 @@ import {
getCustomMaskClosedStateAfterPointRemoval,
removeCustomMaskPoints,
} from "@/masks/custom-path";
import type { CustomMask } from "@/masks/types";
import type { CustomMask } from "@/model/decorations/mask";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/model";
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 "@/masks/types";
import type { CustomMask } from "@/model/decorations/mask";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/model";
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 { SceneTracks, MaskableElement } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/model";
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 "@/masks/types";
import type { SceneTracks, MaskableElement } from "@/timeline";
import type { Mask } from "@/model/decorations/mask";
import type { MaskableElement, SceneTracks } from "@/model";
export function toggleMaskInvertedOnElement({
element,
@@ -4,7 +4,12 @@ import {
type CommandResult,
} from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
import {
elementId as toElementId,
type SceneTracks,
type TimelineElement,
type TimelineTrack,
} from "@/model";
import {
buildEmptyTrack,
validateElementTrackCompatibility,
@@ -117,7 +122,7 @@ export class MoveElementCommand extends Command {
return createElementSelectionResult(
this.moves.map(({ elementId, targetTrackId }) => ({
trackId: targetTrackId,
elementId,
elementId: toElementId(elementId),
})),
);
}
@@ -3,24 +3,23 @@ import {
createElementSelectionResult,
type CommandResult,
} from "@/commands/base-command";
import type { SceneTracks, TimelineElement } from "@/timeline";
import {
elementId,
type ElementRef,
type SceneTracks,
type TimelineElement,
} from "@/model";
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: { trackId: string; elementId: string }[] = [];
private rightSideElements: ElementRef[] = [];
private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: MediaTime;
private readonly splitTime: number;
private readonly retainSide: "both" | "left" | "right";
constructor({
@@ -29,7 +28,7 @@ export class SplitElementsCommand extends Command {
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
splitTime: MediaTime;
splitTime: number;
retainSide?: "both" | "left" | "right";
}) {
super();
@@ -38,7 +37,7 @@ export class SplitElementsCommand extends Command {
this.retainSide = retainSide;
}
getRightSideElements(): { trackId: string; elementId: string }[] {
getRightSideElements(): ElementRef[] {
return this.rightSideElements;
}
@@ -79,39 +78,21 @@ export class SplitElementsCommand extends Command {
return [element];
}
const relativeTime = subMediaTime({
a: this.splitTime,
b: element.startTime,
});
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = subMediaTime({
a: element.duration,
b: relativeTime,
});
const rightVisibleDuration = element.duration - relativeTime;
const retimeRef = isRetimableElement(element)
? element.retime
: undefined;
// 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 leftSourceSpan = getSourceSpanAtClipTime({
clipTime: leftVisibleDuration,
retime: retimeRef,
});
const totalSourceSpan = roundMediaTime({
time: getSourceSpanAtClipTime({
clipTime: element.duration,
retime: retimeRef,
}),
});
const rightSourceSpan = subMediaTime({
a: totalSourceSpan,
b: leftSourceSpan,
const totalSourceSpan = getSourceSpanAtClipTime({
clipTime: element.duration,
retime: retimeRef,
});
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations,
splitTime: relativeTime,
@@ -119,28 +100,19 @@ 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: leftTrimEnd,
trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
} else if (this.retainSide === "right") {
const newId = generateUUID();
const newId = elementId(generateUUID());
this.rightSideElements.push({
trackId: track.id,
elementId: newId,
@@ -151,14 +123,15 @@ export class SplitElementsCommand extends Command {
id: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: rightTrimStart,
trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
} else {
const secondElementId = generateUUID();
// "both" - split into two pieces
const secondElementId = elementId(generateUUID());
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
@@ -167,7 +140,7 @@ export class SplitElementsCommand extends Command {
{
...element,
duration: leftVisibleDuration,
trimEnd: leftTrimEnd,
trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@@ -177,7 +150,7 @@ export class SplitElementsCommand extends Command {
id: secondElementId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: rightTrimStart,
trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@@ -10,11 +10,12 @@ import {
resolveTrackPlacement,
} from "@/timeline/placement";
import { updateElementInSceneTracks } from "@/timeline/track-element-update";
import type {
SceneTracks,
TimelineElement,
VideoElement,
} from "@/timeline/types";
import {
elementId,
type SceneTracks,
type TimelineElement,
type VideoElement,
} from "@/model";
import { generateUUID } from "@/utils/id";
export class ToggleSourceAudioSeparationCommand extends Command {
@@ -75,7 +76,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
...buildSeparatedAudioElement({
sourceElement: videoElement,
}),
id: generateUUID(),
id: elementId(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 "@/timeline";
import type { SceneTracks, TimelineElement } from "@/model";
import {
findTrackInSceneTracks,
updateElementInSceneTracks,
@@ -1,5 +1,5 @@
import { Command, type CommandResult } from "@/commands/base-command";
import type { SceneTracks, TrackType } from "@/timeline";
import type { SceneTracks, TrackType } from "@/model";
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 "@/timeline";
import type { SceneTracks } from "@/model";
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 "@/timeline";
import type { SceneTracks } from "@/model";
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 "@/timeline";
import type { SceneTracks } from "@/model";
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 "@/timeline";
import type { SceneTracks } from "@/model";
import { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command {
+6 -19
View File
@@ -1,24 +1,15 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
formatTimecode,
type FrameRate,
type TimeCodeFormat,
} from "opencut-wasm";
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
import { cn } from "@/utils/ui";
import {
parseMediaTimecode,
snapSeekMediaTime,
type MediaTime,
} from "@/wasm";
interface EditableTimecodeProps {
time: MediaTime;
duration: MediaTime;
time: number;
duration: number;
format?: TimeCodeFormat;
fps: FrameRate;
onTimeChange?: ({ time }: { time: MediaTime }) => void;
onTimeChange?: ({ time }: { time: number }) => void;
className?: string;
disabled?: boolean;
}
@@ -55,11 +46,7 @@ export function EditableTimecode({
};
const applyEdit = () => {
const parsedTime = parseMediaTimecode({
timeCode: inputValue,
format,
fps,
});
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
if (parsedTime == null) {
setHasError(true);
@@ -67,7 +54,7 @@ export function EditableTimecode({
}
const clampedTime = duration
? snapSeekMediaTime({ time: parsedTime, duration, fps })
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
: parsedTime;
onTimeChange?.({ time: clampedTime });
@@ -1,241 +1,240 @@
"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;
}
"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;
}
@@ -26,7 +26,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
import { TICKS_PER_SECOND } 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 "@/timeline";
import { MASKABLE_ELEMENT_TYPES } from "@/model";
import type { MediaAsset } from "@/media/types";
import { cn } from "@/utils/ui";
import {
@@ -255,11 +255,11 @@ function MediaAssetDraggable({
startTime,
}: {
asset: MediaAsset;
startTime: MediaTime;
startTime: number;
}) => {
const duration =
asset.duration != null
? mediaTimeFromSeconds({ seconds: asset.duration })
? Math.round(asset.duration * TICKS_PER_SECOND)
: DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: asset.id,
@@ -1,6 +1,6 @@
"use client";
import type { ParamDefinition, NumberParamDefinition } from "@/params";
import type { NumberParamDefinition, ParamDefinition } from "@/model/decorations/param-values";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
@@ -1,25 +1,22 @@
import { useEditor } from "@/editor/use-editor";
import { getElementLocalTime } from "@/animation";
import { addMediaTime, mediaTime, type MediaTime } from "@/wasm";
export function useElementPlayhead({
startTime,
duration,
}: {
startTime: MediaTime;
duration: MediaTime;
startTime: number;
duration: number;
}) {
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const localTime = mediaTime({
ticks: getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: startTime,
elementDuration: duration,
}),
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: startTime,
elementDuration: duration,
});
const isPlayheadWithinElementRange =
playheadTime >= startTime &&
playheadTime <= addMediaTime({ a: startTime, b: duration });
playheadTime <= startTime + duration;
return { localTime, isPlayheadWithinElementRange };
}
@@ -1,96 +1,95 @@
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,
};
}
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,
};
}
@@ -1,175 +1,174 @@
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,
};
}
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,
};
}
@@ -7,17 +7,14 @@ import {
hasKeyframesForPath,
upsertPathKeyframe,
} from "@/animation";
import type {
ElementAnimations,
} from "@/animation/types";
import type { ElementAnimations } from "@/model/decorations/animations";
import {
coerceAnimationParamValue,
getAnimationParamDefaultInterpolation,
getAnimationParamValueKind,
} from "@/animation/animated-params";
import type { ParamDefinition } from "@/params";
import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { TimelineElement } from "@/model";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
@@ -42,7 +39,7 @@ export function useKeyframedParamProperty({
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
localTime: MediaTime;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
buildBaseUpdates: ({
@@ -1,17 +1,5 @@
import type { ReactNode } from "react";
import type {
EffectElement,
GraphicElement,
ImageElement,
MaskableElement,
RetimableElement,
StickerElement,
TextElement,
VisualElement,
VideoElement,
AudioElement,
TimelineElement,
} from "@/timeline";
import type { AudioElement, EffectElement, GraphicElement, ImageElement, MaskableElement, RetimableElement, StickerElement, TextElement, TimelineElement, VideoElement, VisualElement } from "@/model";
import type { MediaAsset } from "@/media/types";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -6,7 +6,6 @@ import {
type CopyContext,
type PasteContext,
} from "@/clipboard";
import type { MediaTime } from "@/wasm";
export class ClipboardManager {
private entry: ClipboardEntry | null = null;
@@ -35,7 +34,7 @@ export class ClipboardManager {
return true;
}
paste({ time }: { time?: MediaTime } = {}): boolean {
paste({ time }: { time?: number } = {}): boolean {
if (!this.entry) {
return false;
}
@@ -65,7 +64,7 @@ export class ClipboardManager {
};
}
private getPasteContext({ time }: { time?: MediaTime }): PasteContext {
private getPasteContext({ time }: { time?: number }): PasteContext {
return {
editor: this.editor,
selectedElements: this.editor.selection.getSelectedElements(),
+1 -1
View File
@@ -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 "@/timeline/types";
import type { SceneTracks } from "@/model";
interface CommandHistoryEntry {
command: Command;
+26 -47
View File
@@ -1,26 +1,20 @@
import type { EditorCore } from "@/core";
import {
addMediaTime,
clampMediaTime,
type MediaTime,
mediaTimeFromSeconds,
roundFrameTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { TICKS_PER_SECOND } from "@/wasm";
import { roundToFrame } from "opencut-wasm";
export class PlaybackManager {
private isPlaying = false;
private currentTime: MediaTime = ZERO_MEDIA_TIME;
private currentTime = 0;
private volume = 1;
private muted = false;
private previousVolume = 1;
private isScrubbing = false;
private listeners = new Set<() => void>();
private updateListeners = new Set<(time: MediaTime) => void>();
private seekListeners = new Set<(time: MediaTime) => void>();
private updateListeners = new Set<(time: number) => void>();
private seekListeners = new Set<(time: number) => void>();
private playbackTimer: number | null = null;
private playbackStartWallTime = 0;
private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
private playbackStartTime = 0;
private timelineScopeBound = false;
constructor(private editor: EditorCore) {}
@@ -46,7 +40,7 @@ export class PlaybackManager {
}
if (this.currentTime >= maxTime) {
this.seek({ time: ZERO_MEDIA_TIME });
this.seek({ time: 0 });
}
this.isPlaying = true;
@@ -68,7 +62,7 @@ export class PlaybackManager {
}
}
seek({ time }: { time: MediaTime }): void {
seek({ time }: { time: number }): void {
this.currentTime = this.clampTimeToTimeline(time);
if (this.isPlaying) {
this.playbackStartWallTime = performance.now();
@@ -115,7 +109,7 @@ export class PlaybackManager {
return this.isPlaying;
}
getCurrentTime(): MediaTime {
getCurrentTime(): number {
return this.currentTime;
}
@@ -141,12 +135,12 @@ export class PlaybackManager {
return () => this.listeners.delete(listener);
}
onUpdate(listener: (time: MediaTime) => void): () => void {
onUpdate(listener: (time: number) => void): () => void {
this.updateListeners.add(listener);
return () => this.updateListeners.delete(listener);
}
onSeek(listener: (time: MediaTime) => void): () => void {
onSeek(listener: (time: number) => void): () => void {
this.seekListeners.add(listener);
return () => this.seekListeners.delete(listener);
}
@@ -171,7 +165,6 @@ export class PlaybackManager {
if (timeChanged) {
this.notifySeek(this.currentTime);
this.dispatchSeekEvent(this.currentTime);
}
}
@@ -181,18 +174,6 @@ 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);
@@ -216,42 +197,40 @@ export class PlaybackManager {
const fps = this.editor.project.getActive()?.settings.fps;
const elapsedSeconds =
(performance.now() - this.playbackStartWallTime) / 1000;
const rawTime = addMediaTime({
a: this.playbackStartTime,
b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
});
const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime;
const rawTime =
this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
const newTime = fps
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
: rawTime;
const maxTime = this.editor.timeline.getTotalDuration();
if (newTime >= maxTime) {
this.pause();
this.currentTime = maxTime;
this.notify();
this.notifySeek(maxTime);
this.dispatchSeekEvent(maxTime);
return;
this.notifySeek(maxTime);
return;
}
this.currentTime = newTime;
this.notifyUpdate(newTime);
this.dispatchUpdateEvent(newTime);
this.playbackTimer = requestAnimationFrame(this.updateTime);
};
private clampTimeToTimeline(time: MediaTime): MediaTime {
private clampTimeToTimeline(time: number): number {
const maxTime = this.editor.timeline.getTotalDuration();
return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime });
return Math.max(0, Math.min(maxTime, time));
}
private dispatchSeekEvent(time: MediaTime): void {
if (typeof window === "undefined") {
return;
private notifySeek(time: number): void {
for (const fn of this.seekListeners) {
fn(time);
}
}
private dispatchUpdateEvent(time: MediaTime): void {
if (typeof window === "undefined") {
return;
private notifyUpdate(time: number): void {
for (const fn of this.updateListeners) {
fn(time);
}
}
}
+9 -10
View File
@@ -1,5 +1,5 @@
import type { EditorCore } from "@/core";
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
import type { SceneTracks, TScene } from "@/model";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
@@ -21,7 +21,6 @@ import {
ToggleBookmarkCommand,
UpdateBookmarkCommand,
} from "@/commands/scene";
import type { MediaTime } from "@/wasm";
export class ScenesManager {
private active: TScene | null = null;
@@ -107,12 +106,12 @@ export class ScenesManager {
this.notify();
}
async toggleBookmark({ time }: { time: MediaTime }): Promise<void> {
async toggleBookmark({ time }: { time: number }): Promise<void> {
const command = new ToggleBookmarkCommand(time);
this.editor.command.execute({ command });
}
isBookmarked({ time }: { time: MediaTime }): boolean {
isBookmarked({ time }: { time: number }): boolean {
const activeScene = this.getActiveScene();
const activeProject = this.editor.project.getActive();
@@ -126,7 +125,7 @@ export class ScenesManager {
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
}
async removeBookmark({ time }: { time: MediaTime }): Promise<void> {
async removeBookmark({ time }: { time: number }): Promise<void> {
const command = new RemoveBookmarkCommand(time);
this.editor.command.execute({ command });
}
@@ -135,8 +134,8 @@ export class ScenesManager {
time,
updates,
}: {
time: MediaTime;
updates: Partial<Omit<Bookmark, "time">>;
time: number;
updates: Partial<{ note: string; color: string; duration: number }>;
}): Promise<void> {
const command = new UpdateBookmarkCommand(time, updates);
this.editor.command.execute({ command });
@@ -146,14 +145,14 @@ export class ScenesManager {
fromTime,
toTime,
}: {
fromTime: MediaTime;
toTime: MediaTime;
fromTime: number;
toTime: number;
}): Promise<void> {
const command = new MoveBookmarkCommand(fromTime, toTime);
this.editor.command.execute({ command });
}
getBookmarkAtTime({ time }: { time: MediaTime }) {
getBookmarkAtTime({ time }: { time: number }) {
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 "@/timeline/types";
import type { ElementRef } from "@/model";
export class SelectionManager {
private selectedElements: ElementRef[] = [];
+20 -31
View File
@@ -1,33 +1,22 @@
import type { EditorCore } from "@/core";
import type { ElementBounds } from "@/preview/element-bounds";
import type { ParamValues } from "@/params";
import type {
SceneTracks,
TrackType,
TimelineTrack,
TimelineElement,
RetimeConfig,
} from "@/timeline";
import type { ParamValues } from "@/model/decorations/param-values";
import type { RetimeConfig, SceneTracks, TimelineElement, TimelineTrack, TrackType } from "@/model";
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 {
AnimationPath,
AnimationInterpolation,
AnimationValue,
AnimationValueForPath,
ScalarCurveKeyframePatch,
} from "@/animation/types";
import type { AnimationInterpolation, AnimationPath, AnimationValue, AnimationValueForPath } from "@/model/decorations/animations";
import type { 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,
@@ -97,10 +86,10 @@ export class TimelineManager {
pushHistory = true,
}: {
elementId: string;
trimStart: MediaTime;
trimEnd: MediaTime;
startTime?: MediaTime;
duration?: MediaTime;
trimStart: number;
trimEnd: number;
startTime?: number;
duration?: number;
pushHistory?: boolean;
}): void {
const trackId = this.findTrackIdForElement({ elementId });
@@ -190,7 +179,7 @@ export class TimelineManager {
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
splitTime: MediaTime;
splitTime: number;
retainSide?: "both" | "left" | "right";
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand({
@@ -202,20 +191,20 @@ export class TimelineManager {
return command.getRightSideElements();
}
getTotalDuration(): MediaTime {
getTotalDuration(): number {
const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
return ZERO_MEDIA_TIME;
return 0;
}
return calculateTotalDuration({ tracks: activeScene.tracks });
}
getLastFrameTime(): MediaTime {
getLastFrameTime(): number {
const duration = this.getTotalDuration();
const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration;
return lastFrameMediaTime({ duration, fps });
return lastFrameTime({ duration, rate: fps }) ?? duration;
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
@@ -485,7 +474,7 @@ export class TimelineManager {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
time: MediaTime;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -602,7 +591,7 @@ export class TimelineManager {
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
time: MediaTime;
time: number;
}): void {
const command = new RetimeKeyframeCommand({
trackId,
@@ -660,7 +649,7 @@ export class TimelineManager {
elementId: string;
effectId: string;
paramKey: string;
time: MediaTime;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
@@ -709,9 +698,9 @@ export class TimelineManager {
updates,
}: {
updates: readonly {
trackId: string;
elementId: string;
updates: Partial<TimelineElement>;
readonly trackId: string;
readonly elementId: string;
readonly updates: Partial<TimelineElement>;
}[];
}): void {
let changedOverlayCount = 0;
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import type { ParamValues } from "@/params";
import type { Effect } from "@/effects/types";
import type { EffectElement, VisualElement } from "@/timeline";
import type { ParamValues } from "@/model/decorations/param-values";
import type { Effect } from "@/model/decorations/effect";
import type { EffectElement, VisualElement } from "@/model";
import { effectsRegistry } from "@/effects";
import { useEditor } from "@/editor/use-editor";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+2 -1
View File
@@ -1,4 +1,5 @@
import type { EffectDefinition, EffectPass } from "@/effects/types";
import type { EffectPass } from "@/model/decorations/effect";
import type { EffectDefinition } from "@/effects/types";
export const GAUSSIAN_BLUR_SHADER = "gaussian-blur";
+4 -3
View File
@@ -1,9 +1,10 @@
import { generateUUID } from "@/utils/id";
import { buildDefaultParamValues } from "@/params/registry";
import { effectsRegistry } from "./registry";
import type { ParamValues } from "@/params";
import type { Effect, EffectDefinition, EffectPass } from "@/effects/types";
import { VISUAL_ELEMENT_TYPES } from "@/timeline";
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";
export { effectsRegistry } from "./registry";
export { registerDefaultEffects } from "./definitions";
+8 -23
View File
@@ -1,27 +1,12 @@
import type { ParamDefinition, ParamValues } from "@/params";
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
import type { EffectPass, EffectPassTemplate } 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 type {
Effect,
EffectPass,
EffectPassTemplate,
EffectUniformValue,
} from "@/model/decorations/effect";
export interface EffectRendererConfig {
passes: EffectPassTemplate[];
+241 -239
View File
@@ -1,239 +1,241 @@
"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,
}}
/>
);
}
"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,
}}
/>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition } from "@/params";
import type { ParamDefinition } from "@/model/decorations/param-values";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition } from "@/params";
import type { ParamDefinition } from "@/model/decorations/param-values";
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 "@/params";
import type { ParamDefinition } from "@/model/decorations/param-values";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition } from "@/params";
import type { ParamDefinition } from "@/model/decorations/param-values";
export type GraphicStrokeAlign = "inside" | "center" | "outside";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition } from "@/params";
import type { ParamDefinition } from "@/model/decorations/param-values";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
+149 -149
View File
@@ -1,149 +1,149 @@
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";
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";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition, ParamValues } from "@/params";
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
export const DEFAULT_GRAPHIC_SOURCE_SIZE = 512;
+1 -6
View File
@@ -15,12 +15,7 @@ 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 "@/masks/types";
import type { CustomMaskParams, RectangleMaskParams, SplitMaskParams, TextMaskParams } from "@/model/decorations/mask";
const bounds: ElementBounds = {
cx: 200,
+3 -3
View File
@@ -1,8 +1,8 @@
"use client";
import type { MaskableElement } from "@/timeline";
import type { Mask, MaskType, TextMask } from "@/masks/types";
import type { NumberParamDefinition, SelectParamDefinition } from "@/params";
import type { MaskableElement } from "@/model";
import type { Mask, MaskType, TextMask } from "@/model/decorations/mask";
import type { NumberParamDefinition, SelectParamDefinition } from "@/model/decorations/param-values";
import { masksRegistry, buildDefaultMaskInstance } from "@/masks";
import { useEditor } from "@/editor/use-editor";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+2 -10
View File
@@ -1,14 +1,6 @@
import type { ElementBounds } from "@/preview/element-bounds";
export interface CustomMaskPathPoint {
id: string;
x: number;
y: number;
inX: number;
inY: number;
outX: number;
outY: number;
}
import type { CustomMaskPathPoint } from "@/model/decorations/mask";
export type { CustomMaskPathPoint } from "@/model/decorations/mask";
export type CustomMaskHandlePart = "anchor" | "in" | "out";
+3 -9
View File
@@ -3,15 +3,9 @@ import {
MIN_MASK_DIMENSION,
} from "@/masks/dimensions";
import { computeFeatherUpdate } from "../param-update";
import type {
BaseMaskParams,
MaskDefaultContext,
MaskFeatures,
MaskInteractionDefinition,
MaskParamUpdateArgs,
RectangleMaskParams,
} from "@/masks/types";
import type { NumberParamDefinition, ParamDefinition } from "@/params";
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 {
getBoxMaskHandlePositions,
getBoxMaskOverlays,
@@ -1,8 +1,5 @@
import type {
MaskDefaultContext,
MaskDefinition,
RectangleMaskParams,
} from "@/masks/types";
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefaultContext, MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
+3 -9
View File
@@ -1,15 +1,9 @@
import { generateUUID } from "@/utils/id";
import type { ParamDefinition } from "@/params";
import type { ParamDefinition } from "@/model/decorations/param-values";
import { PEN_CURSOR } from "@/preview/components/cursors";
import type { ElementBounds } from "@/preview/element-bounds";
import type {
CustomMask,
CustomMaskParams,
MaskDefinition,
MaskHandlePosition,
MaskOverlay,
MaskParamUpdateArgs,
} from "@/masks/types";
import type { CustomMask, CustomMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition, MaskHandlePosition, MaskOverlay, MaskParamUpdateArgs } from "@/masks/types";
import {
buildCustomMaskPath2D,
buildCustomMaskSvgPath,
+2 -1
View File
@@ -1,4 +1,5 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
+2 -1
View File
@@ -1,4 +1,5 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
+2 -1
View File
@@ -1,4 +1,5 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
+2 -1
View File
@@ -1,4 +1,5 @@
import type { BaseMaskParams, MaskDefinition } from "@/masks/types";
import type { BaseMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } from "@/masks/types";
import { masksRegistry, type MaskIconProps } from "../registry";
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
import { customMaskDefinition } from "./custom";
+2 -1
View File
@@ -1,4 +1,5 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
+2 -5
View File
@@ -1,9 +1,6 @@
import { computeFeatherUpdate } from "../param-update";
import type {
MaskDefinition,
MaskParamUpdateArgs,
SplitMaskParams,
} from "@/masks/types";
import type { SplitMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition, MaskParamUpdateArgs } from "@/masks/types";
import { halfPlaneSign, lineEdgeIntersection } from "../utils";
import {
getLineMaskHandlePositions,
+2 -1
View File
@@ -1,4 +1,5 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } 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