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

|
||||
# OpenCut (Legacy)
|
||||
|
||||
This is the original OpenCut codebase. It's archived and no longer maintained.
|
||||
|
||||
The rewrite is happening at [opencut-app/opencut](https://github.com/opencut-app/opencut).
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.2.9",
|
||||
"opencut-wasm": "^0.2.10",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
@@ -5,7 +5,16 @@ import { useTimelineStore } from "@/timeline/timeline-store";
|
||||
import { useActionHandler } from "@/actions/use-action-handler";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
maxMediaTime,
|
||||
mediaTime,
|
||||
mediaTimeFromSeconds,
|
||||
minMediaTime,
|
||||
subMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
|
||||
import { getElementsAtTime, hasMediaId } from "@/timeline";
|
||||
import { cancelInteraction } from "@/editor/cancel-interaction";
|
||||
@@ -83,7 +92,7 @@ export function useEditorActions() {
|
||||
if (editor.playback.getIsPlaying()) {
|
||||
editor.playback.toggle();
|
||||
}
|
||||
editor.playback.seek({ time: 0 });
|
||||
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -92,11 +101,15 @@ export function useEditorActions() {
|
||||
"seek-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
time: minMediaTime({
|
||||
a: editor.timeline.getTotalDuration(),
|
||||
b: addMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -106,8 +119,15 @@ export function useEditorActions() {
|
||||
"seek-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -117,15 +137,20 @@ export function useEditorActions() {
|
||||
"frame-step-forward",
|
||||
() => {
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + ticksPerFrame,
|
||||
const ticksPerFrame = mediaTime({
|
||||
ticks: Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
),
|
||||
});
|
||||
editor.playback.seek({
|
||||
time: minMediaTime({
|
||||
a: editor.timeline.getTotalDuration(),
|
||||
b: addMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: ticksPerFrame,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -134,11 +159,19 @@ export function useEditorActions() {
|
||||
"frame-step-backward",
|
||||
() => {
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
const ticksPerFrame = mediaTime({
|
||||
ticks: Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
),
|
||||
});
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: ticksPerFrame,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -148,11 +181,15 @@ export function useEditorActions() {
|
||||
"jump-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
time: minMediaTime({
|
||||
a: editor.timeline.getTotalDuration(),
|
||||
b: addMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -162,8 +199,15 @@ export function useEditorActions() {
|
||||
"jump-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -172,7 +216,7 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"goto-start",
|
||||
() => {
|
||||
editor.playback.seek({ time: 0 });
|
||||
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamNumericRange,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
|
||||
describe("animated params", () => {
|
||||
test("snaps and clamps number params", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
},
|
||||
value: 0.62,
|
||||
}),
|
||||
).toBe(0.5);
|
||||
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
},
|
||||
value: 1.2,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("rejects NaN and non-number values for number params", () => {
|
||||
const param = {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number" as const,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
|
||||
});
|
||||
|
||||
test("passthrough with step <= 0 guard", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "x",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 0,
|
||||
},
|
||||
value: 0.123,
|
||||
}),
|
||||
).toBe(0.123);
|
||||
});
|
||||
|
||||
test("accepts valid select values", () => {
|
||||
const param = {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select" as const,
|
||||
default: "normal",
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
|
||||
expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
|
||||
});
|
||||
|
||||
test("rejects select values outside the allowed options", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select",
|
||||
default: "normal",
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
],
|
||||
},
|
||||
value: "screen",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects non-string select values", () => {
|
||||
const param = {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select" as const,
|
||||
default: "normal",
|
||||
options: [{ value: "normal", label: "Normal" }],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
|
||||
});
|
||||
|
||||
test("boolean params accept booleans and reject other types", () => {
|
||||
const param = {
|
||||
key: "visible",
|
||||
label: "Visible",
|
||||
type: "boolean" as const,
|
||||
default: true,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
|
||||
expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
|
||||
expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
|
||||
});
|
||||
|
||||
test("color params accept strings and reject other types", () => {
|
||||
const param = {
|
||||
key: "fill",
|
||||
label: "Fill",
|
||||
type: "color" as const,
|
||||
default: "#ffffff",
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
|
||||
expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
});
|
||||
|
||||
test("getAnimationParamValueKind maps param type to binding kind", () => {
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("number");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("color");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("discrete");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
type: "select",
|
||||
default: "a",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
}),
|
||||
).toBe("discrete");
|
||||
});
|
||||
|
||||
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("hold");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
type: "select",
|
||||
default: "a",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
}),
|
||||
).toBe("hold");
|
||||
});
|
||||
|
||||
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0.5,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
},
|
||||
}),
|
||||
).toEqual({ min: 0, max: 1, step: 0.1 });
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { DynamicAnimationPathValue, NumericSpec } from "./types";
|
||||
|
||||
export function getAnimationParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "number" | "color" | "discrete" {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getAnimationParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "linear" | "hold" {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
export function getAnimationParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): NumericSpec | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `coerceAnimationParamValue` accepts `unknown` rather than a narrow
|
||||
* `DynamicAnimationPathValue` because it doubles as a runtime gate for
|
||||
* untrusted inputs (persisted state, paste payloads, programmatic updates).
|
||||
* The caller does not need to pre-validate; null means "this value cannot live
|
||||
* on this param".
|
||||
*/
|
||||
export function coerceAnimationParamValue({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: unknown;
|
||||
}): DynamicAnimationPathValue | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const minValue = param.min;
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
NormalizedCubicBezier,
|
||||
ScalarAnimationKey,
|
||||
} from "@/animation/types";
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
|
||||
const VALUE_EPSILON = 1e-6;
|
||||
|
||||
@@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
|
||||
|
||||
return {
|
||||
rightHandle: {
|
||||
dt: spanTime * x1,
|
||||
dt: roundMediaTime({ time: spanTime * x1 }),
|
||||
dv: effectiveSpanValue * y1,
|
||||
},
|
||||
leftHandle: {
|
||||
dt: spanTime * (x2 - 1),
|
||||
dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
|
||||
dv: effectiveSpanValue * (y2 - 1),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { Effect } from "@/effects/types";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
EffectParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { removeElementKeyframe } from "./keyframes";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
@@ -56,23 +55,26 @@ export function parseEffectParamPath({
|
||||
}
|
||||
|
||||
export function resolveEffectParamsAtTime({
|
||||
effect,
|
||||
effectId,
|
||||
params,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
effect: Effect;
|
||||
effectId: string;
|
||||
params: ParamValues;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
const resolved: ParamValues = {};
|
||||
|
||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
||||
for (const [paramKey, staticValue] of Object.entries(params)) {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
resolved[paramKey] = animations?.bindings[path]
|
||||
? resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime,
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: staticValue,
|
||||
})
|
||||
: staticValue;
|
||||
|
||||
@@ -2,11 +2,7 @@ import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import {
|
||||
getGraphicDefinition,
|
||||
resolveGraphicParams,
|
||||
} from "@/graphics";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
@@ -39,33 +35,29 @@ export function parseGraphicParamPath({
|
||||
}
|
||||
|
||||
export function resolveGraphicParamsAtTime({
|
||||
element,
|
||||
params,
|
||||
definitions,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
params: ParamValues;
|
||||
definitions: ParamDefinition[];
|
||||
animations?: ElementAnimations;
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const baseParams = resolveGraphicParams(definition, element.params);
|
||||
const resolved: ParamValues = { ...baseParams };
|
||||
const resolved: ParamValues = { ...params };
|
||||
|
||||
for (const param of definition.params) {
|
||||
for (const param of definitions) {
|
||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||
if (!element.animations?.bindings[path]) {
|
||||
if (!animations?.bindings[path]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||
animations: element.animations,
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseParams[param.key] ?? param.default,
|
||||
fallbackValue: params[param.key] ?? param.default,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,24 +23,8 @@ export {
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveAnimationPathValueAtTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeById,
|
||||
@@ -75,15 +59,6 @@ export {
|
||||
resolveEffectParamsAtTime,
|
||||
} from "./effect-param-channel";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
coerceAnimationValueForParam,
|
||||
resolveAnimationTarget,
|
||||
getParamValueKind,
|
||||
getParamDefaultInterpolation,
|
||||
type AnimationPathDescriptor,
|
||||
} from "./target-resolver";
|
||||
|
||||
export {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
@@ -94,3 +69,19 @@ export {
|
||||
type EasingMode,
|
||||
getEasingModeForKind,
|
||||
} from "./binding-values";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
isAnimationPropertyPath,
|
||||
} from "./path";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
@@ -8,13 +8,14 @@ import type {
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
} from "@/animation/types";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { mediaTime } from "@/wasm";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
solveBezierProgressForTime,
|
||||
} from "./bezier";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
function byTimeAscending({
|
||||
leftTime,
|
||||
@@ -63,9 +64,13 @@ function normalizeRightHandle({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||
const span = mediaTime({
|
||||
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||
});
|
||||
return {
|
||||
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||
dt: mediaTime({
|
||||
ticks: Math.min(span, Math.max(0, handle.dt)),
|
||||
}),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
@@ -83,9 +88,13 @@ function normalizeLeftHandle({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||
const span = mediaTime({
|
||||
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||
});
|
||||
return {
|
||||
dt: Math.max(-span, Math.min(0, handle.dt)),
|
||||
dt: mediaTime({
|
||||
ticks: Math.max(-span, Math.min(0, handle.dt)),
|
||||
}),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
} from "./interpolation";
|
||||
import { isAnimationPath } from "./target-resolver";
|
||||
import { isAnimationPath } from "./path";
|
||||
|
||||
function getBindingFallbackValue({
|
||||
channel,
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
ScalarCurveKeyframePatch,
|
||||
ScalarSegmentType,
|
||||
} from "@/animation/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
cloneAnimationBinding,
|
||||
createAnimationBinding,
|
||||
@@ -35,6 +34,13 @@ import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
} from "./property-registry";
|
||||
import {
|
||||
type MediaTime,
|
||||
roundMediaTime,
|
||||
subMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
|
||||
function isNearlySameTime({
|
||||
leftTime,
|
||||
@@ -171,7 +177,7 @@ function createScalarKey({
|
||||
previousKey,
|
||||
}: {
|
||||
id: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: number;
|
||||
interpolation?: AnimationInterpolation;
|
||||
previousKey?: ScalarAnimationKey;
|
||||
@@ -195,7 +201,7 @@ function createDiscreteKey({
|
||||
value,
|
||||
}: {
|
||||
id: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: string | boolean;
|
||||
}): DiscreteAnimationKey {
|
||||
return {
|
||||
@@ -241,7 +247,7 @@ function getTargetKeyMetadata({
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
keyframeId?: string;
|
||||
}) {
|
||||
const normalizedChannel =
|
||||
@@ -280,7 +286,7 @@ function upsertDiscreteChannelKey({
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: DiscreteAnimationChannel | undefined;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: string | boolean;
|
||||
keyframeId?: string;
|
||||
}): DiscreteAnimationChannel {
|
||||
@@ -337,7 +343,7 @@ function upsertScalarChannelKey({
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel | undefined;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: number;
|
||||
interpolation?: AnimationInterpolation;
|
||||
defaultInterpolation?: AnimationInterpolation;
|
||||
@@ -442,7 +448,7 @@ export function upsertPathKeyframe({
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -537,7 +543,7 @@ export function upsertElementKeyframe({
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -576,7 +582,7 @@ export function upsertKeyframe({
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -642,7 +648,7 @@ export function retimeKeyframe({
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}): AnimationChannel | undefined {
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
@@ -887,7 +893,7 @@ export function clampAnimationsToDuration({
|
||||
duration,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
duration: number;
|
||||
duration: MediaTime;
|
||||
}): ElementAnimations | undefined {
|
||||
if (!animations || duration <= 0) {
|
||||
return undefined;
|
||||
@@ -923,7 +929,7 @@ function splitDiscreteChannelAtTime({
|
||||
shouldIncludeSplitBoundary,
|
||||
}: {
|
||||
channel: DiscreteAnimationChannel | undefined;
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
leftBoundaryId: string;
|
||||
rightBoundaryId: string;
|
||||
shouldIncludeSplitBoundary: boolean;
|
||||
@@ -939,7 +945,10 @@ function splitDiscreteChannelAtTime({
|
||||
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
|
||||
let rightKeys = normalizedChannel.keys
|
||||
.filter((key) => key.time >= splitTime)
|
||||
.map((key) => ({ ...key, time: key.time - splitTime }));
|
||||
.map((key) => ({
|
||||
...key,
|
||||
time: subMediaTime({ a: key.time, b: splitTime }),
|
||||
}));
|
||||
|
||||
if (shouldIncludeSplitBoundary) {
|
||||
const hasBoundaryOnLeft = leftKeys.some((key) =>
|
||||
@@ -967,7 +976,7 @@ function splitDiscreteChannelAtTime({
|
||||
rightKeys = [
|
||||
createDiscreteKey({
|
||||
id: rightBoundaryId,
|
||||
time: 0,
|
||||
time: ZERO_MEDIA_TIME,
|
||||
value: boundaryValue as string | boolean,
|
||||
}),
|
||||
...rightKeys,
|
||||
@@ -993,7 +1002,7 @@ function splitScalarChannelAtTime({
|
||||
shouldIncludeSplitBoundary,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel | undefined;
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
leftBoundaryId: string;
|
||||
rightBoundaryId: string;
|
||||
shouldIncludeSplitBoundary: boolean;
|
||||
@@ -1009,7 +1018,10 @@ function splitScalarChannelAtTime({
|
||||
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
|
||||
let rightKeys = normalizedChannel.keys
|
||||
.filter((key) => key.time >= splitTime)
|
||||
.map((key) => ({ ...key, time: key.time - splitTime }));
|
||||
.map((key) => ({
|
||||
...key,
|
||||
time: subMediaTime({ a: key.time, b: splitTime }),
|
||||
}));
|
||||
|
||||
const hasBoundaryOnLeft = leftKeys.some((key) =>
|
||||
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
|
||||
@@ -1089,7 +1101,7 @@ function splitScalarChannelAtTime({
|
||||
{
|
||||
...leftKey,
|
||||
rightHandle: {
|
||||
dt: q0.x - p0.x,
|
||||
dt: roundMediaTime({ time: q0.x - p0.x }),
|
||||
dv: q0.y - p0.y,
|
||||
},
|
||||
},
|
||||
@@ -1098,7 +1110,7 @@ function splitScalarChannelAtTime({
|
||||
time: splitTime,
|
||||
value: boundaryValue,
|
||||
leftHandle: {
|
||||
dt: r0.x - splitPoint.x,
|
||||
dt: roundMediaTime({ time: r0.x - splitPoint.x }),
|
||||
dv: r0.y - splitPoint.y,
|
||||
},
|
||||
segmentToNext: leftKey.segmentToNext,
|
||||
@@ -1108,10 +1120,10 @@ function splitScalarChannelAtTime({
|
||||
rightKeys = [
|
||||
{
|
||||
id: rightBoundaryId,
|
||||
time: 0,
|
||||
time: ZERO_MEDIA_TIME,
|
||||
value: boundaryValue,
|
||||
rightHandle: {
|
||||
dt: r1.x - splitPoint.x,
|
||||
dt: roundMediaTime({ time: r1.x - splitPoint.x }),
|
||||
dv: r1.y - splitPoint.y,
|
||||
},
|
||||
segmentToNext: "bezier",
|
||||
@@ -1119,9 +1131,9 @@ function splitScalarChannelAtTime({
|
||||
},
|
||||
{
|
||||
...rightKey,
|
||||
time: rightKey.time - splitTime,
|
||||
time: subMediaTime({ a: rightKey.time, b: splitTime }),
|
||||
leftHandle: {
|
||||
dt: q2.x - p3.x,
|
||||
dt: roundMediaTime({ time: q2.x - p3.x }),
|
||||
dv: q2.y - p3.y,
|
||||
},
|
||||
},
|
||||
@@ -1129,7 +1141,7 @@ function splitScalarChannelAtTime({
|
||||
.filter((key) => key.time > rightKey.time)
|
||||
.map((key) => ({
|
||||
...key,
|
||||
time: key.time - splitTime,
|
||||
time: subMediaTime({ a: key.time, b: splitTime }),
|
||||
})),
|
||||
];
|
||||
} else {
|
||||
@@ -1145,7 +1157,7 @@ function splitScalarChannelAtTime({
|
||||
rightKeys = [
|
||||
createScalarKey({
|
||||
id: rightBoundaryId,
|
||||
time: 0,
|
||||
time: ZERO_MEDIA_TIME,
|
||||
value: boundaryValue,
|
||||
interpolation: getScalarSegmentInterpolation({
|
||||
segment: leftKey.segmentToNext,
|
||||
@@ -1201,7 +1213,7 @@ export function splitAnimationsAtTime({
|
||||
shouldIncludeSplitBoundary = true,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
shouldIncludeSplitBoundary?: boolean;
|
||||
}): {
|
||||
leftAnimations: ElementAnimations | undefined;
|
||||
@@ -1317,7 +1329,7 @@ export function retimeElementKeyframe({
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}): ElementAnimations | undefined {
|
||||
const binding = getBinding({ animations, propertyPath });
|
||||
if (!binding) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
|
||||
import { ANIMATION_PROPERTY_PATHS } from "./types";
|
||||
import { isEffectParamPath } from "./effect-param-channel";
|
||||
import { isGraphicParamPath } from "./graphic-param-channel";
|
||||
|
||||
const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import type {
|
||||
AnimationColorPropertyPath,
|
||||
AnimationNumericPropertyPath,
|
||||
AnimationPath,
|
||||
AnimationPropertyPath,
|
||||
AnimationValueForPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
@@ -37,107 +33,6 @@ export function getElementLocalTime({
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: {
|
||||
x: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.x,
|
||||
}),
|
||||
y: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.y,
|
||||
}),
|
||||
},
|
||||
scaleX: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleX,
|
||||
}),
|
||||
scaleY: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleY,
|
||||
}),
|
||||
rotate: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationNumericPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationColorPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||
animations,
|
||||
propertyPath,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.positionX",
|
||||
@@ -50,7 +51,13 @@ export interface AnimationPropertyValueMap {
|
||||
"background.offsetY": number;
|
||||
"background.cornerRadius": number;
|
||||
}
|
||||
export type DynamicAnimationPathValue = ParamValues[string];
|
||||
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]
|
||||
@@ -77,13 +84,13 @@ export type TangentMode = "auto" | "aligned" | "broken" | "flat";
|
||||
export type ChannelExtrapolationMode = "hold" | "linear";
|
||||
|
||||
export interface CurveHandle {
|
||||
dt: number;
|
||||
dt: MediaTime;
|
||||
dv: number;
|
||||
}
|
||||
|
||||
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
|
||||
id: string;
|
||||
time: number; // relative to element start time
|
||||
time: MediaTime; // relative to element start time
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
@@ -209,7 +216,7 @@ export interface ScalarCurveKeyframePatch {
|
||||
export interface ElementKeyframe {
|
||||
propertyPath: AnimationPath;
|
||||
id: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
AnimationColorPropertyPath,
|
||||
AnimationNumericPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "./types";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationNumericPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationColorPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
KeyframeClipboardCurvePatch,
|
||||
KeyframeClipboardItem,
|
||||
} from "../types";
|
||||
import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm";
|
||||
|
||||
function resolveSingleSourceElement({
|
||||
selectedKeyframes,
|
||||
@@ -80,7 +81,7 @@ function buildClipboardItem({
|
||||
element: TimelineElement;
|
||||
propertyPath: KeyframeClipboardItem["propertyPath"];
|
||||
keyframeId: string;
|
||||
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
|
||||
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
@@ -136,10 +137,11 @@ export const KeyframesClipboardHandler = {
|
||||
}
|
||||
|
||||
const minTime = Math.min(...rawItems.map((item) => item.time));
|
||||
const minTimeMedia = roundMediaTime({ time: minTime });
|
||||
const items = rawItems
|
||||
.map(({ time, ...item }) => ({
|
||||
...item,
|
||||
timeOffset: time - minTime,
|
||||
timeOffset: subMediaTime({ a: time, b: minTimeMedia }),
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ElementRef,
|
||||
TrackType,
|
||||
} from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export interface ElementClipboardItem {
|
||||
trackId: string;
|
||||
@@ -26,7 +27,7 @@ export interface KeyframeClipboardCurvePatch {
|
||||
|
||||
export interface KeyframeClipboardItem {
|
||||
propertyPath: AnimationPath;
|
||||
timeOffset: number;
|
||||
timeOffset: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
curvePatches: KeyframeClipboardCurvePatch[];
|
||||
@@ -61,7 +62,7 @@ export interface PasteContext {
|
||||
editor: EditorCore;
|
||||
selectedElements: ElementRef[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}
|
||||
|
||||
export interface ClipboardHandler<TType extends ClipboardEntryType> {
|
||||
|
||||
@@ -3,13 +3,14 @@ import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { updateSceneInArray } from "@/timeline/scenes";
|
||||
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class MoveBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
|
||||
constructor(
|
||||
private fromTime: number,
|
||||
private toTime: number,
|
||||
private fromTime: MediaTime,
|
||||
private toTime: MediaTime,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
getFrameTime,
|
||||
removeBookmarkFromArray,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class RemoveBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private frameTime: number = 0;
|
||||
private frameTime: MediaTime = 0 as MediaTime;
|
||||
|
||||
constructor(private time: number) {
|
||||
constructor(private time: MediaTime) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
getFrameTime,
|
||||
toggleBookmarkInArray,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class ToggleBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private frameTime: number = 0;
|
||||
private frameTime: MediaTime = 0 as MediaTime;
|
||||
|
||||
constructor(private time: number) {
|
||||
constructor(private time: MediaTime) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
getFrameTime,
|
||||
updateBookmarkInArray,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class UpdateBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
|
||||
constructor(
|
||||
private time: number,
|
||||
private time: MediaTime,
|
||||
private updates: Partial<Omit<Bookmark, "time">>,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
resolveAnimationTarget,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
@@ -9,7 +8,15 @@ import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { KeyframeClipboardItem } from "@/clipboard";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
addMediaTime,
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
function pasteKeyframesIntoElement({
|
||||
element,
|
||||
@@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({
|
||||
clipboardItems,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
clipboardItems: KeyframeClipboardItem[];
|
||||
}): TimelineElement {
|
||||
let nextElement = element;
|
||||
@@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyframeTime = Math.max(
|
||||
0,
|
||||
Math.min(time + item.timeOffset, nextElement.duration),
|
||||
);
|
||||
const keyframeTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({
|
||||
a: addMediaTime({ a: time, b: item.timeOffset }),
|
||||
b: nextElement.duration,
|
||||
}),
|
||||
});
|
||||
const nextAnimations = upsertPathKeyframe({
|
||||
animations: nextElement.animations,
|
||||
propertyPath: item.propertyPath,
|
||||
@@ -79,7 +89,7 @@ export class PasteKeyframesCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly clipboardItems: KeyframeClipboardItem[];
|
||||
|
||||
constructor({
|
||||
@@ -90,7 +100,7 @@ export class PasteKeyframesCommand extends Command {
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
clipboardItems: KeyframeClipboardItem[];
|
||||
}) {
|
||||
super();
|
||||
|
||||
@@ -13,18 +13,25 @@ import {
|
||||
enforceMainTrackStart,
|
||||
} from "@/timeline/placement";
|
||||
import { cloneAnimations } from "@/animation";
|
||||
import {
|
||||
addMediaTime,
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
subMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private pastedElements: { trackId: string; elementId: string }[] = [];
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly clipboardItems: ElementClipboardItem[];
|
||||
|
||||
constructor({
|
||||
time,
|
||||
clipboardItems,
|
||||
}: {
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
clipboardItems: ElementClipboardItem[];
|
||||
}) {
|
||||
super();
|
||||
@@ -39,8 +46,12 @@ export class PasteCommand extends Command {
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
this.pastedElements = [];
|
||||
|
||||
const minStart = Math.min(
|
||||
...this.clipboardItems.map((item) => item.element.startTime),
|
||||
const minStart = this.clipboardItems.reduce(
|
||||
(earliestStartTime, item) =>
|
||||
item.element.startTime < earliestStartTime
|
||||
? item.element.startTime
|
||||
: earliestStartTime,
|
||||
this.clipboardItems[0].element.startTime,
|
||||
);
|
||||
|
||||
let updatedTracks = this.savedState;
|
||||
@@ -97,12 +108,18 @@ export class PasteCommand extends Command {
|
||||
targetTrackId: targetTrack.id,
|
||||
requestedStartTime: earliestElement.startTime,
|
||||
});
|
||||
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
||||
const delta = subMediaTime({
|
||||
a: adjustedEarliestStartTime,
|
||||
b: earliestElement.startTime,
|
||||
});
|
||||
|
||||
if (delta !== 0) {
|
||||
if (delta !== ZERO_MEDIA_TIME) {
|
||||
elementsForPlacement = elementsToAdd.map((element) => ({
|
||||
...element,
|
||||
startTime: Math.max(0, element.startTime + delta),
|
||||
startTime: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: addMediaTime({ a: element.startTime, b: delta }),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -168,14 +185,20 @@ function buildPastedElements({
|
||||
time,
|
||||
}: {
|
||||
items: ElementClipboardItem[];
|
||||
minStart: number;
|
||||
time: number;
|
||||
minStart: MediaTime;
|
||||
time: MediaTime;
|
||||
}): TimelineElement[] {
|
||||
const elementsToAdd: TimelineElement[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const relativeOffset = item.element.startTime - minStart;
|
||||
const startTime = Math.max(0, time + relativeOffset);
|
||||
const relativeOffset = subMediaTime({
|
||||
a: item.element.startTime,
|
||||
b: minStart,
|
||||
});
|
||||
const startTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: addMediaTime({ a: time, b: relativeOffset }),
|
||||
});
|
||||
const newElementId = generateUUID();
|
||||
|
||||
elementsToAdd.push({
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 }[];
|
||||
@@ -119,7 +120,7 @@ function buildDuplicateElement({
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
id: string;
|
||||
startTime: number;
|
||||
startTime: MediaTime;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
...element,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveTrackPlacement,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/timeline/placement";
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
|
||||
type InsertElementPlacement =
|
||||
| { mode: "explicit"; trackId: string }
|
||||
@@ -266,7 +267,12 @@ export class InsertElementCommand extends Command {
|
||||
placementResult.kind === "existingTrack"
|
||||
? {
|
||||
...element,
|
||||
startTime: placementResult.adjustedStartTime ?? element.startTime,
|
||||
startTime:
|
||||
placementResult.adjustedStartTime !== undefined
|
||||
? roundMediaTime({
|
||||
time: placementResult.adjustedStartTime,
|
||||
})
|
||||
: element.startTime,
|
||||
}
|
||||
: element;
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { EditorCore } from "@/core";
|
||||
import {
|
||||
hasKeyframesForPath,
|
||||
removeElementKeyframe,
|
||||
resolveAnimationTarget,
|
||||
} 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 { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
|
||||
function removeKeyframeAndPersist({
|
||||
element,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation";
|
||||
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 { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
|
||||
export class RetimeKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
@@ -11,7 +18,7 @@ export class RetimeKeyframeCommand extends Command {
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly keyframeId: string;
|
||||
private readonly nextTime: number;
|
||||
private readonly nextTime: MediaTime;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
@@ -24,7 +31,7 @@ export class RetimeKeyframeCommand extends Command {
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
nextTime: number;
|
||||
nextTime: MediaTime;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
@@ -47,7 +54,10 @@ export class RetimeKeyframeCommand extends Command {
|
||||
return element;
|
||||
}
|
||||
|
||||
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
|
||||
const boundedTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({ a: this.nextTime, b: element.duration }),
|
||||
});
|
||||
return {
|
||||
...element,
|
||||
animations: retimeElementKeyframe({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
resolveAnimationTarget,
|
||||
updateScalarKeyframeCurve,
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ScalarCurveKeyframePatch,
|
||||
|
||||
@@ -2,13 +2,19 @@ import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import {
|
||||
buildEffectParamPath,
|
||||
resolveAnimationTarget,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
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";
|
||||
|
||||
export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
@@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
private readonly paramKey: string;
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly value: number | string | boolean;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
@@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: number | string | boolean;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
const boundedTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({ a: this.time, b: element.duration }),
|
||||
});
|
||||
const propertyPath = buildEffectParamPath({
|
||||
effectId: this.effectId,
|
||||
paramKey: this.paramKey,
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation";
|
||||
import { upsertPathKeyframe } from "@/animation";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class UpsertKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly value: AnimationValue;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
@@ -31,7 +38,7 @@ export class UpsertKeyframeCommand extends Command {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -63,7 +70,10 @@ export class UpsertKeyframeCommand extends Command {
|
||||
return element;
|
||||
}
|
||||
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
const boundedTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({ a: this.time, b: element.duration }),
|
||||
});
|
||||
return {
|
||||
...element,
|
||||
animations: upsertPathKeyframe({
|
||||
|
||||
@@ -9,12 +9,18 @@ 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 readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly splitTime: number;
|
||||
private readonly splitTime: MediaTime;
|
||||
private readonly retainSide: "both" | "left" | "right";
|
||||
|
||||
constructor({
|
||||
@@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command {
|
||||
retainSide = "both",
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}) {
|
||||
super();
|
||||
@@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command {
|
||||
return [element];
|
||||
}
|
||||
|
||||
const relativeTime = this.splitTime - element.startTime;
|
||||
const relativeTime = subMediaTime({
|
||||
a: this.splitTime,
|
||||
b: element.startTime,
|
||||
});
|
||||
const leftVisibleDuration = relativeTime;
|
||||
const rightVisibleDuration = element.duration - relativeTime;
|
||||
const rightVisibleDuration = subMediaTime({
|
||||
a: element.duration,
|
||||
b: relativeTime,
|
||||
});
|
||||
const retimeRef = isRetimableElement(element)
|
||||
? element.retime
|
||||
: undefined;
|
||||
const leftSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: leftVisibleDuration,
|
||||
retime: retimeRef,
|
||||
// Snap the source-side split point exactly once and derive the right
|
||||
// half from it. Independently rounding both spans (left and total)
|
||||
// would let a 1-tick rounding error desynchronise them, breaking the
|
||||
// invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`.
|
||||
// See the same discipline in `compute-resize.ts` (snap-once comment).
|
||||
const leftSourceSpan = roundMediaTime({
|
||||
time: getSourceSpanAtClipTime({
|
||||
clipTime: leftVisibleDuration,
|
||||
retime: retimeRef,
|
||||
}),
|
||||
});
|
||||
const totalSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: element.duration,
|
||||
retime: retimeRef,
|
||||
const totalSourceSpan = roundMediaTime({
|
||||
time: getSourceSpanAtClipTime({
|
||||
clipTime: element.duration,
|
||||
retime: retimeRef,
|
||||
}),
|
||||
});
|
||||
const rightSourceSpan = subMediaTime({
|
||||
a: totalSourceSpan,
|
||||
b: leftSourceSpan,
|
||||
});
|
||||
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations: element.animations,
|
||||
splitTime: relativeTime,
|
||||
@@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command {
|
||||
});
|
||||
let splitResult: TimelineElement[];
|
||||
|
||||
const leftTrimEnd = addMediaTime({
|
||||
a: element.trimEnd,
|
||||
b: rightSourceSpan,
|
||||
});
|
||||
const rightTrimStart = addMediaTime({
|
||||
a: element.trimStart,
|
||||
b: leftSourceSpan,
|
||||
});
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
trimEnd: leftTrimEnd,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
@@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command {
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
trimStart: rightTrimStart,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
@@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command {
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
trimEnd: leftTrimEnd,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
@@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command {
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
trimStart: rightTrimStart,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
|
||||
import {
|
||||
formatTimecode,
|
||||
type FrameRate,
|
||||
type TimeCodeFormat,
|
||||
} from "opencut-wasm";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
parseMediaTimecode,
|
||||
snapSeekMediaTime,
|
||||
type MediaTime,
|
||||
} from "@/wasm";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: number;
|
||||
duration: number;
|
||||
time: MediaTime;
|
||||
duration: MediaTime;
|
||||
format?: TimeCodeFormat;
|
||||
fps: FrameRate;
|
||||
onTimeChange?: ({ time }: { time: number }) => void;
|
||||
onTimeChange?: ({ time }: { time: MediaTime }) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -46,7 +55,11 @@ export function EditableTimecode({
|
||||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
|
||||
const parsedTime = parseMediaTimecode({
|
||||
timeCode: inputValue,
|
||||
format,
|
||||
fps,
|
||||
});
|
||||
|
||||
if (parsedTime == null) {
|
||||
setHasError(true);
|
||||
@@ -54,7 +67,7 @@ export function EditableTimecode({
|
||||
}
|
||||
|
||||
const clampedTime = duration
|
||||
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
|
||||
? snapSeekMediaTime({ time: parsedTime, duration, fps })
|
||||
: parsedTime;
|
||||
|
||||
onTimeChange?.({ time: clampedTime });
|
||||
|
||||
@@ -1,240 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
onAddToTimeline,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", handleDragOver);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
editor.timeline.dragSource.begin({
|
||||
dataTransfer: event.dataTransfer,
|
||||
dragData,
|
||||
});
|
||||
|
||||
setDragPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.({ e: event });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
editor.timeline.dragSource.end();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "w-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-auto w-full cursor-default flex-col gap-1 p-",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
title={name}
|
||||
>
|
||||
<span className="sr-only">{name}</span>
|
||||
<span aria-hidden="true">
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative w-full", containerClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="size-6 shrink-0 overflow-hidden rounded-sm">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-9999"
|
||||
style={{
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
|
||||
>
|
||||
<div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({
|
||||
className,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
tooltipText?: string;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipText) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
onAddToTimeline,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", handleDragOver);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
editor.timeline.dragSource.begin({
|
||||
dataTransfer: event.dataTransfer,
|
||||
dragData,
|
||||
});
|
||||
|
||||
setDragPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.({ e: event });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
editor.timeline.dragSource.end();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "w-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-auto w-full cursor-default flex-col gap-1 p-",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
title={name}
|
||||
>
|
||||
<span className="sr-only">{name}</span>
|
||||
<span aria-hidden="true">
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative w-full", containerClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="size-6 shrink-0 overflow-hidden rounded-sm">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-9999"
|
||||
style={{
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
|
||||
>
|
||||
<div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({
|
||||
className,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
tooltipText?: string;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipText) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useFileUpload } from "@/media/use-file-upload";
|
||||
import { invokeAction } from "@/actions";
|
||||
@@ -255,11 +255,11 @@ function MediaAssetDraggable({
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
startTime: MediaTime;
|
||||
}) => {
|
||||
const duration =
|
||||
asset.duration != null
|
||||
? Math.round(asset.duration * TICKS_PER_SECOND)
|
||||
? mediaTimeFromSeconds({ seconds: asset.duration })
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: asset.id,
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { getElementLocalTime } from "@/animation";
|
||||
import { addMediaTime, mediaTime, type MediaTime } from "@/wasm";
|
||||
|
||||
export function useElementPlayhead({
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
startTime: MediaTime;
|
||||
duration: MediaTime;
|
||||
}) {
|
||||
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: startTime,
|
||||
elementDuration: duration,
|
||||
const localTime = mediaTime({
|
||||
ticks: getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: startTime,
|
||||
elementDuration: duration,
|
||||
}),
|
||||
});
|
||||
const isPlayheadWithinElementRange =
|
||||
playheadTime >= startTime &&
|
||||
playheadTime <= startTime + duration;
|
||||
playheadTime <= addMediaTime({ a: startTime, b: duration });
|
||||
|
||||
return { localTime, isPlayheadWithinElementRange };
|
||||
}
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export function useKeyframedColorProperty({
|
||||
trackId,
|
||||
@@ -21,7 +22,7 @@ export function useKeyframedColorProperty({
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedColor: string;
|
||||
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -27,7 +28,7 @@ export function useKeyframedNumberProperty({
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
displayValue: string;
|
||||
parse: (input: string) => number | null;
|
||||
|
||||
+10
-7
@@ -3,18 +3,21 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
coerceAnimationValueForParam,
|
||||
getKeyframeAtTime,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
@@ -39,7 +42,7 @@ export function useKeyframedParamProperty({
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
buildBaseUpdates: ({
|
||||
@@ -79,12 +82,12 @@ export function useKeyframedParamProperty({
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
kind: getAnimationParamValueKind({ param }),
|
||||
defaultInterpolation: getAnimationParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationValueForParam({
|
||||
coerceAnimationParamValue({
|
||||
param,
|
||||
value: nextValue,
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type CopyContext,
|
||||
type PasteContext,
|
||||
} from "@/clipboard";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class ClipboardManager {
|
||||
private entry: ClipboardEntry | null = null;
|
||||
@@ -34,7 +35,7 @@ export class ClipboardManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
paste({ time }: { time?: number } = {}): boolean {
|
||||
paste({ time }: { time?: MediaTime } = {}): boolean {
|
||||
if (!this.entry) {
|
||||
return false;
|
||||
}
|
||||
@@ -64,7 +65,7 @@ export class ClipboardManager {
|
||||
};
|
||||
}
|
||||
|
||||
private getPasteContext({ time }: { time?: number }): PasteContext {
|
||||
private getPasteContext({ time }: { time?: MediaTime }): PasteContext {
|
||||
return {
|
||||
editor: this.editor,
|
||||
selectedElements: this.editor.selection.getSelectedElements(),
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
clampMediaTime,
|
||||
type MediaTime,
|
||||
mediaTimeFromSeconds,
|
||||
roundFrameTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class PlaybackManager {
|
||||
private isPlaying = false;
|
||||
private currentTime = 0;
|
||||
private currentTime: MediaTime = ZERO_MEDIA_TIME;
|
||||
private volume = 1;
|
||||
private muted = false;
|
||||
private previousVolume = 1;
|
||||
private isScrubbing = false;
|
||||
private listeners = new Set<() => void>();
|
||||
private updateListeners = new Set<(time: number) => void>();
|
||||
private seekListeners = new Set<(time: number) => void>();
|
||||
private updateListeners = new Set<(time: MediaTime) => void>();
|
||||
private seekListeners = new Set<(time: MediaTime) => void>();
|
||||
private playbackTimer: number | null = null;
|
||||
private playbackStartWallTime = 0;
|
||||
private playbackStartTime = 0;
|
||||
private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
|
||||
private timelineScopeBound = false;
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
@@ -40,7 +46,7 @@ export class PlaybackManager {
|
||||
}
|
||||
|
||||
if (this.currentTime >= maxTime) {
|
||||
this.seek({ time: 0 });
|
||||
this.seek({ time: ZERO_MEDIA_TIME });
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
@@ -62,7 +68,7 @@ export class PlaybackManager {
|
||||
}
|
||||
}
|
||||
|
||||
seek({ time }: { time: number }): void {
|
||||
seek({ time }: { time: MediaTime }): void {
|
||||
this.currentTime = this.clampTimeToTimeline(time);
|
||||
if (this.isPlaying) {
|
||||
this.playbackStartWallTime = performance.now();
|
||||
@@ -109,7 +115,7 @@ export class PlaybackManager {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
getCurrentTime(): number {
|
||||
getCurrentTime(): MediaTime {
|
||||
return this.currentTime;
|
||||
}
|
||||
|
||||
@@ -135,12 +141,12 @@ export class PlaybackManager {
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
onUpdate(listener: (time: number) => void): () => void {
|
||||
onUpdate(listener: (time: MediaTime) => void): () => void {
|
||||
this.updateListeners.add(listener);
|
||||
return () => this.updateListeners.delete(listener);
|
||||
}
|
||||
|
||||
onSeek(listener: (time: number) => void): () => void {
|
||||
onSeek(listener: (time: MediaTime) => void): () => void {
|
||||
this.seekListeners.add(listener);
|
||||
return () => this.seekListeners.delete(listener);
|
||||
}
|
||||
@@ -165,6 +171,7 @@ export class PlaybackManager {
|
||||
|
||||
if (timeChanged) {
|
||||
this.notifySeek(this.currentTime);
|
||||
this.dispatchSeekEvent(this.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +181,18 @@ export class PlaybackManager {
|
||||
});
|
||||
}
|
||||
|
||||
private notifyUpdate(time: MediaTime): void {
|
||||
this.updateListeners.forEach((fn) => {
|
||||
fn(time);
|
||||
});
|
||||
}
|
||||
|
||||
private notifySeek(time: MediaTime): void {
|
||||
this.seekListeners.forEach((fn) => {
|
||||
fn(time);
|
||||
});
|
||||
}
|
||||
|
||||
private startTimer(): void {
|
||||
if (this.playbackTimer) {
|
||||
cancelAnimationFrame(this.playbackTimer);
|
||||
@@ -197,40 +216,42 @@ export class PlaybackManager {
|
||||
const fps = this.editor.project.getActive()?.settings.fps;
|
||||
const elapsedSeconds =
|
||||
(performance.now() - this.playbackStartWallTime) / 1000;
|
||||
const rawTime =
|
||||
this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
|
||||
const newTime = fps
|
||||
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
|
||||
: rawTime;
|
||||
const rawTime = addMediaTime({
|
||||
a: this.playbackStartTime,
|
||||
b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
|
||||
});
|
||||
const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime;
|
||||
const maxTime = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (newTime >= maxTime) {
|
||||
this.pause();
|
||||
this.currentTime = maxTime;
|
||||
this.notify();
|
||||
this.notifySeek(maxTime);
|
||||
return;
|
||||
this.notifySeek(maxTime);
|
||||
this.dispatchSeekEvent(maxTime);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTime = newTime;
|
||||
this.notifyUpdate(newTime);
|
||||
this.dispatchUpdateEvent(newTime);
|
||||
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
||||
};
|
||||
|
||||
private clampTimeToTimeline(time: number): number {
|
||||
private clampTimeToTimeline(time: MediaTime): MediaTime {
|
||||
const maxTime = this.editor.timeline.getTotalDuration();
|
||||
return Math.max(0, Math.min(maxTime, time));
|
||||
return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime });
|
||||
}
|
||||
|
||||
private notifySeek(time: number): void {
|
||||
for (const fn of this.seekListeners) {
|
||||
fn(time);
|
||||
private dispatchSeekEvent(time: MediaTime): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private notifyUpdate(time: number): void {
|
||||
for (const fn of this.updateListeners) {
|
||||
fn(time);
|
||||
private dispatchUpdateEvent(time: MediaTime): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { SceneTracks, TScene } from "@/timeline";
|
||||
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import {
|
||||
getMainScene,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ToggleBookmarkCommand,
|
||||
UpdateBookmarkCommand,
|
||||
} from "@/commands/scene";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class ScenesManager {
|
||||
private active: TScene | null = null;
|
||||
@@ -106,12 +107,12 @@ export class ScenesManager {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async toggleBookmark({ time }: { time: number }): Promise<void> {
|
||||
async toggleBookmark({ time }: { time: MediaTime }): Promise<void> {
|
||||
const command = new ToggleBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
isBookmarked({ time }: { time: number }): boolean {
|
||||
isBookmarked({ time }: { time: MediaTime }): boolean {
|
||||
const activeScene = this.getActiveScene();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
@@ -125,7 +126,7 @@ export class ScenesManager {
|
||||
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
|
||||
}
|
||||
|
||||
async removeBookmark({ time }: { time: number }): Promise<void> {
|
||||
async removeBookmark({ time }: { time: MediaTime }): Promise<void> {
|
||||
const command = new RemoveBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
@@ -134,8 +135,8 @@ export class ScenesManager {
|
||||
time,
|
||||
updates,
|
||||
}: {
|
||||
time: number;
|
||||
updates: Partial<{ note: string; color: string; duration: number }>;
|
||||
time: MediaTime;
|
||||
updates: Partial<Omit<Bookmark, "time">>;
|
||||
}): Promise<void> {
|
||||
const command = new UpdateBookmarkCommand(time, updates);
|
||||
this.editor.command.execute({ command });
|
||||
@@ -145,14 +146,14 @@ export class ScenesManager {
|
||||
fromTime,
|
||||
toTime,
|
||||
}: {
|
||||
fromTime: number;
|
||||
toTime: number;
|
||||
fromTime: MediaTime;
|
||||
toTime: MediaTime;
|
||||
}): Promise<void> {
|
||||
const command = new MoveBookmarkCommand(fromTime, toTime);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
getBookmarkAtTime({ time }: { time: number }) {
|
||||
getBookmarkAtTime({ time }: { time: MediaTime }) {
|
||||
const activeScene = this.active;
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
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,
|
||||
@@ -24,10 +25,9 @@ import type {
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveAnimationTarget,
|
||||
resolveAnimationPathValueAtTime,
|
||||
} from "@/animation";
|
||||
import { lastFrameTime } from "opencut-wasm";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
@@ -97,10 +97,10 @@ export class TimelineManager {
|
||||
pushHistory = true,
|
||||
}: {
|
||||
elementId: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
startTime?: number;
|
||||
duration?: number;
|
||||
trimStart: MediaTime;
|
||||
trimEnd: MediaTime;
|
||||
startTime?: MediaTime;
|
||||
duration?: MediaTime;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const trackId = this.findTrackIdForElement({ elementId });
|
||||
@@ -190,7 +190,7 @@ export class TimelineManager {
|
||||
retainSide = "both",
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}): { trackId: string; elementId: string }[] {
|
||||
const command = new SplitElementsCommand({
|
||||
@@ -202,20 +202,20 @@ export class TimelineManager {
|
||||
return command.getRightSideElements();
|
||||
}
|
||||
|
||||
getTotalDuration(): number {
|
||||
getTotalDuration(): MediaTime {
|
||||
const activeScene = this.editor.scenes.getActiveSceneOrNull();
|
||||
if (!activeScene) {
|
||||
return 0;
|
||||
return ZERO_MEDIA_TIME;
|
||||
}
|
||||
|
||||
return calculateTotalDuration({ tracks: activeScene.tracks });
|
||||
}
|
||||
|
||||
getLastFrameTime(): number {
|
||||
getLastFrameTime(): MediaTime {
|
||||
const duration = this.getTotalDuration();
|
||||
const fps = this.editor.project.getActive()?.settings.fps;
|
||||
if (!fps || duration <= 0) return duration;
|
||||
return lastFrameTime({ duration, rate: fps }) ?? duration;
|
||||
return lastFrameMediaTime({ duration, fps });
|
||||
}
|
||||
|
||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||
@@ -485,7 +485,7 @@ export class TimelineManager {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -602,7 +602,7 @@ export class TimelineManager {
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}): void {
|
||||
const command = new RetimeKeyframeCommand({
|
||||
trackId,
|
||||
@@ -660,7 +660,7 @@ export class TimelineManager {
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: number;
|
||||
interpolation?: "linear" | "hold";
|
||||
keyframeId?: string;
|
||||
@@ -709,9 +709,9 @@ export class TimelineManager {
|
||||
updates,
|
||||
}: {
|
||||
updates: readonly {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly updates: Partial<TimelineElement>;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<TimelineElement>;
|
||||
}[];
|
||||
}): void {
|
||||
let changedOverlayCount = 0;
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
useKeyframedParamProperty,
|
||||
type KeyframedParamPropertyResult,
|
||||
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import type { GraphicElement } from "@/timeline";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/graphics";
|
||||
import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
@@ -24,6 +23,7 @@ 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();
|
||||
|
||||
@@ -48,7 +48,7 @@ export function GraphicTab({
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicParamsAtTime({
|
||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
@@ -103,7 +103,7 @@ function StrokeSection({
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicParamsAtTime({
|
||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
@@ -197,10 +197,11 @@ function AnimatedGraphicParamField({
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedParams,
|
||||
}: {
|
||||
key?: string;
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
element: GraphicElement;
|
||||
localTime: number;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedParams: ParamValues;
|
||||
}) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import { buildDefaultParamValues } from "@/params/registry";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { graphicsRegistry } from "./registry";
|
||||
@@ -68,6 +70,28 @@ export function resolveGraphicParams(
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media";
|
||||
import { InsertElementCommand } from "@/commands/timeline";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { mediaTimeFromSeconds } from "@/wasm";
|
||||
import { isTypableDOMElement } from "@/utils/browser";
|
||||
import type { MediaType } from "@/media/types";
|
||||
|
||||
@@ -75,7 +75,7 @@ export function usePasteMedia() {
|
||||
const assetId = addMediaCmd.getAssetId();
|
||||
const duration =
|
||||
asset.duration != null
|
||||
? Math.round(asset.duration * TICKS_PER_SECOND)
|
||||
? mediaTimeFromSeconds({ seconds: asset.duration })
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const trackType = asset.type === "audio" ? "audio" : "video";
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { TextElement } from "@/timeline";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import { resolveTextLayout } from "@/text/primitives";
|
||||
|
||||
export function TextEditOverlay({
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
|
||||
import { usePreviewViewport } from "./preview-viewport";
|
||||
import { GridPopover } from "./guide-popover";
|
||||
import { usePreviewStore } from "@/preview/preview-store";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export function PreviewToolbar({
|
||||
onToggleFullscreen,
|
||||
@@ -60,7 +63,7 @@ function TimecodeDisplay() {
|
||||
const editor = useEditor();
|
||||
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
|
||||
const fps = useEditor((e) => e.project.getActive().settings.fps);
|
||||
const [currentTime, setCurrentTime] = useState(() =>
|
||||
const [currentTime, setCurrentTime] = useState<MediaTime>(() =>
|
||||
editor.playback.getCurrentTime(),
|
||||
);
|
||||
|
||||
|
||||
@@ -19,6 +19,17 @@ import {
|
||||
} from "@/preview/preview-snap";
|
||||
import type { TCanvasSize } from "@/project/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
hasKeyframesForPath,
|
||||
upsertElementKeyframe,
|
||||
} from "@/animation";
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import { mediaTime, type MediaTime } from "@/wasm";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import type {
|
||||
ElementRef,
|
||||
@@ -50,7 +61,12 @@ interface PendingGesture extends CapturedPointerState {
|
||||
interface DragElementSnapshot {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialBaseTransform: Transform;
|
||||
readonly initialResolvedTransform: Transform;
|
||||
readonly animations: ElementAnimations | undefined;
|
||||
readonly localTime: MediaTime;
|
||||
readonly hasAnimatedPositionX: boolean;
|
||||
readonly hasAnimatedPositionY: boolean;
|
||||
}
|
||||
|
||||
interface DraggingGesture extends CapturedPointerState {
|
||||
@@ -204,8 +220,10 @@ function movedPastDragThreshold({
|
||||
|
||||
function toDragElementSnapshots({
|
||||
elementsWithTracks,
|
||||
currentTime,
|
||||
}: {
|
||||
elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>;
|
||||
currentTime: number;
|
||||
}): DragElementSnapshot[] {
|
||||
const isVisualTrackedElement = (value: {
|
||||
track: TimelineTrack;
|
||||
@@ -215,11 +233,107 @@ function toDragElementSnapshots({
|
||||
|
||||
return elementsWithTracks
|
||||
.filter(isVisualTrackedElement)
|
||||
.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialTransform: element.transform,
|
||||
}));
|
||||
.map(({ track, element }) => {
|
||||
const localTime = mediaTime({
|
||||
ticks: getElementLocalTime({
|
||||
timelineTime: currentTime,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialBaseTransform: element.transform,
|
||||
initialResolvedTransform: resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
}),
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
hasAnimatedPositionX: hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.positionX",
|
||||
}),
|
||||
hasAnimatedPositionY: hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.positionY",
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function upsertPositionKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
value,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: MediaTime;
|
||||
value: number;
|
||||
}): ElementAnimations | undefined {
|
||||
return upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function buildDragPositionUpdates({
|
||||
snapshot,
|
||||
position,
|
||||
}: {
|
||||
snapshot: DragElementSnapshot;
|
||||
position: Point;
|
||||
}): Partial<VisualElement> {
|
||||
const hasAnimatedPosition =
|
||||
snapshot.hasAnimatedPositionX || snapshot.hasAnimatedPositionY;
|
||||
const hasStaticPosition =
|
||||
!snapshot.hasAnimatedPositionX || !snapshot.hasAnimatedPositionY;
|
||||
let animations = snapshot.animations;
|
||||
const updates: Partial<VisualElement> = {};
|
||||
|
||||
if (snapshot.hasAnimatedPositionX) {
|
||||
animations = upsertPositionKeyframe({
|
||||
animations,
|
||||
propertyPath: "transform.positionX",
|
||||
localTime: snapshot.localTime,
|
||||
value: position.x,
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.hasAnimatedPositionY) {
|
||||
animations = upsertPositionKeyframe({
|
||||
animations,
|
||||
propertyPath: "transform.positionY",
|
||||
localTime: snapshot.localTime,
|
||||
value: position.y,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAnimatedPosition) {
|
||||
updates.animations = animations;
|
||||
}
|
||||
|
||||
if (hasStaticPosition) {
|
||||
updates.transform = {
|
||||
...snapshot.initialBaseTransform,
|
||||
position: {
|
||||
x: snapshot.hasAnimatedPositionX
|
||||
? snapshot.initialBaseTransform.position.x
|
||||
: position.x,
|
||||
y: snapshot.hasAnimatedPositionY
|
||||
? snapshot.initialBaseTransform.position.y
|
||||
: position.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
export class PreviewInteractionController {
|
||||
@@ -489,6 +603,7 @@ export class PreviewInteractionController {
|
||||
elementsWithTracks: this.deps.timeline.getElementsWithTracks({
|
||||
elements: dragSelection,
|
||||
}),
|
||||
currentTime: this.deps.scene.getCurrentTime(),
|
||||
});
|
||||
|
||||
if (draggableElements.length === 0) {
|
||||
@@ -536,8 +651,8 @@ export class PreviewInteractionController {
|
||||
const deltaY = currentPos.y - drag.origin.y;
|
||||
|
||||
const proposedPosition = {
|
||||
x: firstElement.initialTransform.position.x + deltaX,
|
||||
y: firstElement.initialTransform.position.y + deltaY,
|
||||
x: firstElement.initialResolvedTransform.position.x + deltaX,
|
||||
y: firstElement.initialResolvedTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
const shouldSnap = !this.deps.input.isShiftHeld();
|
||||
@@ -560,23 +675,21 @@ export class PreviewInteractionController {
|
||||
this.deps.preview.onSnapLinesChange?.(activeLines);
|
||||
|
||||
const deltaSnappedX =
|
||||
snappedPosition.x - firstElement.initialTransform.position.x;
|
||||
snappedPosition.x - firstElement.initialResolvedTransform.position.x;
|
||||
const deltaSnappedY =
|
||||
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||
snappedPosition.y - firstElement.initialResolvedTransform.position.y;
|
||||
|
||||
this.deps.timeline.previewElements(
|
||||
drag.elements.map(({ trackId, elementId, initialTransform }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: {
|
||||
x: initialTransform.position.x + deltaSnappedX,
|
||||
y: initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
drag.elements.map((snapshot) => ({
|
||||
trackId: snapshot.trackId,
|
||||
elementId: snapshot.elementId,
|
||||
updates: buildDragPositionUpdates({
|
||||
snapshot,
|
||||
position: {
|
||||
x: snapshot.initialResolvedTransform.position.x + deltaSnappedX,
|
||||
y: snapshot.initialResolvedTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,11 @@ import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
hasKeyframesForPath,
|
||||
resolveTransformAtTime,
|
||||
setChannel,
|
||||
} from "@/animation";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import type {
|
||||
ElementRef,
|
||||
SceneTracks,
|
||||
|
||||
@@ -1,308 +1,308 @@
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
|
||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||
import { measureTextElement } from "@/text/measure-element";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
|
||||
export interface ElementBounds {
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export interface ElementWithBounds {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TimelineElement;
|
||||
bounds: ElementBounds;
|
||||
}
|
||||
|
||||
function getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
transform: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const containScale = Math.min(
|
||||
canvasWidth / sourceWidth,
|
||||
canvasHeight / sourceHeight,
|
||||
);
|
||||
const scaledWidth = sourceWidth * containScale * transform.scaleX;
|
||||
const scaledHeight = sourceHeight * containScale * transform.scaleY;
|
||||
const cx = canvasWidth / 2 + transform.position.x;
|
||||
const cy = canvasHeight / 2 + transform.position.y;
|
||||
|
||||
return {
|
||||
cx,
|
||||
cy,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
function getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
rect: { left: number; top: number; width: number; height: number };
|
||||
transform: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const localCenterX = rect.left + rect.width / 2;
|
||||
const localCenterY = rect.top + rect.height / 2;
|
||||
const scaledCenterX = localCenterX * transform.scaleX;
|
||||
const scaledCenterY = localCenterY * transform.scaleY;
|
||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
return {
|
||||
cx:
|
||||
canvasWidth / 2 +
|
||||
transform.position.x +
|
||||
scaledCenterX * cos -
|
||||
scaledCenterY * sin,
|
||||
cy:
|
||||
canvasHeight / 2 +
|
||||
transform.position.y +
|
||||
scaledCenterX * sin +
|
||||
scaledCenterY * cos,
|
||||
width: rect.width * transform.scaleX,
|
||||
height: rect.height * transform.scaleY,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
|
||||
* sticker/image/video content area) and base transform. Post-effect spill (blur,
|
||||
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
|
||||
* the canonical element geometry, not visual effect output.
|
||||
*/
|
||||
function getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAsset?: MediaAsset | null;
|
||||
localTime: number;
|
||||
}): ElementBounds | null {
|
||||
if (element.type === "audio" || element.type === "effect") return null;
|
||||
if ("hidden" in element && element.hidden) return null;
|
||||
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
||||
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "graphic") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "text") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
const measured = measureTextElement({
|
||||
element,
|
||||
canvasHeight,
|
||||
localTime,
|
||||
ctx,
|
||||
});
|
||||
|
||||
return getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect: measured.visualRect,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ROTATION_HANDLE_OFFSET = 24;
|
||||
|
||||
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
export type Edge = "right" | "left" | "bottom";
|
||||
|
||||
export function getCornerPosition({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
corner: Corner;
|
||||
}): { x: number; y: number } {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEdgeHandlePosition({
|
||||
bounds,
|
||||
edge,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
edge: Edge;
|
||||
}): { x: number; y: number } {
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
|
||||
const localY = edge === "bottom" ? halfHeight : 0;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
currentTime: number;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAssets: MediaAsset[];
|
||||
}): ElementWithBounds[] {
|
||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||
const orderedTracks = [
|
||||
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
||||
...(!tracks.main.hidden ? [tracks.main] : []),
|
||||
].reverse();
|
||||
|
||||
const result: ElementWithBounds[] = [];
|
||||
|
||||
for (const track of orderedTracks) {
|
||||
const elements = track.elements
|
||||
.filter((element) => !("hidden" in element && element.hidden))
|
||||
.filter(
|
||||
(element) =>
|
||||
currentTime >= element.startTime &&
|
||||
currentTime < element.startTime + element.duration,
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
for (const element of elements) {
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTime,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const mediaAsset =
|
||||
element.type === "video" || element.type === "image"
|
||||
? mediaMap.get(element.mediaId)
|
||||
: undefined;
|
||||
const bounds = getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
});
|
||||
if (bounds) {
|
||||
result.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
element,
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
|
||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||
import { measureTextElement } from "@/text/measure-element";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
} from "@/animation";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
|
||||
export interface ElementBounds {
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export interface ElementWithBounds {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TimelineElement;
|
||||
bounds: ElementBounds;
|
||||
}
|
||||
|
||||
function getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
transform: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const containScale = Math.min(
|
||||
canvasWidth / sourceWidth,
|
||||
canvasHeight / sourceHeight,
|
||||
);
|
||||
const scaledWidth = sourceWidth * containScale * transform.scaleX;
|
||||
const scaledHeight = sourceHeight * containScale * transform.scaleY;
|
||||
const cx = canvasWidth / 2 + transform.position.x;
|
||||
const cy = canvasHeight / 2 + transform.position.y;
|
||||
|
||||
return {
|
||||
cx,
|
||||
cy,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
function getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
rect: { left: number; top: number; width: number; height: number };
|
||||
transform: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const localCenterX = rect.left + rect.width / 2;
|
||||
const localCenterY = rect.top + rect.height / 2;
|
||||
const scaledCenterX = localCenterX * transform.scaleX;
|
||||
const scaledCenterY = localCenterY * transform.scaleY;
|
||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
return {
|
||||
cx:
|
||||
canvasWidth / 2 +
|
||||
transform.position.x +
|
||||
scaledCenterX * cos -
|
||||
scaledCenterY * sin,
|
||||
cy:
|
||||
canvasHeight / 2 +
|
||||
transform.position.y +
|
||||
scaledCenterX * sin +
|
||||
scaledCenterY * cos,
|
||||
width: rect.width * transform.scaleX,
|
||||
height: rect.height * transform.scaleY,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
|
||||
* sticker/image/video content area) and base transform. Post-effect spill (blur,
|
||||
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
|
||||
* the canonical element geometry, not visual effect output.
|
||||
*/
|
||||
function getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAsset?: MediaAsset | null;
|
||||
localTime: number;
|
||||
}): ElementBounds | null {
|
||||
if (element.type === "audio" || element.type === "effect") return null;
|
||||
if ("hidden" in element && element.hidden) return null;
|
||||
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
||||
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "graphic") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "text") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
const measured = measureTextElement({
|
||||
element,
|
||||
canvasHeight,
|
||||
localTime,
|
||||
ctx,
|
||||
});
|
||||
|
||||
return getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect: measured.visualRect,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ROTATION_HANDLE_OFFSET = 24;
|
||||
|
||||
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
export type Edge = "right" | "left" | "bottom";
|
||||
|
||||
export function getCornerPosition({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
corner: Corner;
|
||||
}): { x: number; y: number } {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEdgeHandlePosition({
|
||||
bounds,
|
||||
edge,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
edge: Edge;
|
||||
}): { x: number; y: number } {
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
|
||||
const localY = edge === "bottom" ? halfHeight : 0;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
currentTime: number;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAssets: MediaAsset[];
|
||||
}): ElementWithBounds[] {
|
||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||
const orderedTracks = [
|
||||
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
||||
...(!tracks.main.hidden ? [tracks.main] : []),
|
||||
].reverse();
|
||||
|
||||
const result: ElementWithBounds[] = [];
|
||||
|
||||
for (const track of orderedTracks) {
|
||||
const elements = track.elements
|
||||
.filter((element) => !("hidden" in element && element.hidden))
|
||||
.filter(
|
||||
(element) =>
|
||||
currentTime >= element.startTime &&
|
||||
currentTime < element.startTime + element.duration,
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
for (const element of elements) {
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTime,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const mediaAsset =
|
||||
element.type === "video" || element.type === "image"
|
||||
? mediaMap.get(element.mediaId)
|
||||
: undefined;
|
||||
const bounds = getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
});
|
||||
if (bounds) {
|
||||
result.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
element,
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import type { TScene } from "@/timeline/types";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export type TBackground =
|
||||
| {
|
||||
@@ -20,7 +21,7 @@ export interface TProjectMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail?: string;
|
||||
duration: number;
|
||||
duration: MediaTime;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -37,7 +38,7 @@ export interface TProjectSettings {
|
||||
export interface TTimelineViewState {
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
playheadTime: number;
|
||||
playheadTime: MediaTime;
|
||||
}
|
||||
|
||||
export interface TProject {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Notes
|
||||
|
||||
## `Transform` is misplaced
|
||||
|
||||
`Transform` is currently defined in `apps/web/src/rendering/index.ts`. It's
|
||||
exported from the rendering domain because rendering happens to be one of its
|
||||
consumers — but every other domain that touches scene geometry consumes it too
|
||||
(`@/timeline`, `@/preview`, `@/animation/values`, `@/text`, etc.). It's not
|
||||
"of" rendering any more than `Vector2` would be "of" math.
|
||||
|
||||
`Transform` is closer to a primitive than a domain concept. It's infrastructure:
|
||||
a small value type with no behavior, no dependencies, no domain-specific
|
||||
invariants. Anything that wants to position something on a 2D canvas needs it.
|
||||
|
||||
The codebase should be refactored so it's defined lower-level, rather than
|
||||
sitting in a domain. Candidate homes:
|
||||
|
||||
- `apps/web/src/primitives/transform.ts` (new dir for these value types)
|
||||
- `apps/web/src/geometry/transform.ts` if `Vector2` and friends end up there
|
||||
- `apps/web/src/rendering/transform.ts` is acceptable only if `@/rendering`
|
||||
becomes a primitives folder rather than a domain (it's borderline today —
|
||||
it also exports `BlendMode`, which has the same problem).
|
||||
|
||||
Side effect of fixing this: `@/rendering/animation-values.ts` (which exists
|
||||
only because `Transform` lives next to it) can move into `@/animation/values.ts`
|
||||
alongside the other resolve-at-time helpers, since `Transform` would no longer
|
||||
pull in a domain dependency.
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import { resolveAnimationPathValueAtTime } from "@/animation";
|
||||
import type { Transform } from "./index";
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: {
|
||||
x: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.x,
|
||||
}),
|
||||
y: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.y,
|
||||
}),
|
||||
},
|
||||
scaleX: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleX,
|
||||
}),
|
||||
scaleY: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleY,
|
||||
}),
|
||||
rotate: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -27,17 +27,18 @@ import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/animation";
|
||||
import { resolveOpacityAtTime } from "@/animation/values";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { isPropertyAtDefault } from "./transform-tab";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
type BlendingElement = {
|
||||
id: string;
|
||||
opacity: number;
|
||||
type: ElementType;
|
||||
blendMode?: BlendMode;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
startTime: MediaTime;
|
||||
duration: MediaTime;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
import {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveColorAtTime,
|
||||
resolveGraphicParamsAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
|
||||
import { getElementLocalTime } from "@/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
|
||||
import {
|
||||
buildGaussianBlurPasses,
|
||||
@@ -14,11 +8,16 @@ import {
|
||||
import { effectsRegistry, resolveEffectPasses } from "@/effects";
|
||||
import type { Effect, EffectPass } from "@/effects/types";
|
||||
import { getSourceTimeAtClipTime } from "@/retime";
|
||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||
import {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
resolveGraphicElementParamsAtTime,
|
||||
} from "@/graphics";
|
||||
import {
|
||||
getTextMeasurementContext,
|
||||
measureTextElement,
|
||||
} from "@/text/measure-element";
|
||||
import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
import type { CanvasRenderer } from "./canvas-renderer";
|
||||
import type { AnyBaseNode } from "./nodes/base-node";
|
||||
@@ -113,7 +112,8 @@ function resolveEffectPassGroups({
|
||||
.filter((effect) => effect.enabled)
|
||||
.map((effect) => {
|
||||
const resolvedParams = resolveEffectParamsAtTime({
|
||||
effect,
|
||||
effectId: effect.id,
|
||||
params: effect.params,
|
||||
animations,
|
||||
localTime,
|
||||
});
|
||||
@@ -204,7 +204,7 @@ async function resolveVideoNode({
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: node.params.mediaId,
|
||||
file: node.params.file,
|
||||
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
|
||||
time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
|
||||
});
|
||||
if (!frame) {
|
||||
return null;
|
||||
@@ -304,7 +304,7 @@ function resolveGraphicNode({
|
||||
|
||||
return {
|
||||
...visualState,
|
||||
resolvedParams: resolveGraphicParamsAtTime({
|
||||
resolvedParams: resolveGraphicElementParamsAtTime({
|
||||
element: node.params,
|
||||
localTime: visualState.localTime,
|
||||
}),
|
||||
@@ -421,7 +421,7 @@ async function resolveBackdropSource({
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: node.params.mediaId,
|
||||
file: node.params.file,
|
||||
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
|
||||
time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
|
||||
});
|
||||
if (!frame) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV27ToV28 } from "../transformers/v27-to-v28";
|
||||
|
||||
describe("V27 to V28 Migration", () => {
|
||||
test("rounds persisted media-time floats back to integer ticks", () => {
|
||||
const result = transformProjectV27ToV28({
|
||||
project: {
|
||||
id: "project-v27-float-time",
|
||||
version: 27,
|
||||
metadata: {
|
||||
id: "project-v27-float-time",
|
||||
name: "Project",
|
||||
duration: 2_152_466.3677130044,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
settings: {
|
||||
fps: { numerator: 30, denominator: 1 },
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
timelineViewState: {
|
||||
zoomLevel: 1.25,
|
||||
scrollLeft: 120,
|
||||
playheadTime: 301_234.8,
|
||||
},
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-1",
|
||||
name: "Scene 1",
|
||||
isMain: true,
|
||||
bookmarks: [
|
||||
{
|
||||
time: 120_000.4,
|
||||
duration: 60_000.6,
|
||||
note: "Marker",
|
||||
color: "#ff0000",
|
||||
},
|
||||
],
|
||||
tracks: {
|
||||
main: {
|
||||
id: "main-track",
|
||||
type: "video",
|
||||
name: "Main",
|
||||
muted: false,
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "element-1",
|
||||
type: "video",
|
||||
name: "Clip",
|
||||
startTime: 300_000.49,
|
||||
duration: 2_152_466.3677130044,
|
||||
trimStart: 30_000.2,
|
||||
trimEnd: 15_000.7,
|
||||
sourceDuration: 2_197_467.6,
|
||||
mediaId: "media-1",
|
||||
transform: {
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
animations: {
|
||||
channels: {
|
||||
opacity: {
|
||||
kind: "scalar",
|
||||
keys: [
|
||||
{
|
||||
id: "key-1",
|
||||
time: 1_000.6,
|
||||
value: 1,
|
||||
segmentToNext: "bezier",
|
||||
tangentMode: "flat",
|
||||
leftHandle: { dt: -120.6, dv: 0 },
|
||||
rightHandle: { dt: 60.4, dv: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
overlay: [],
|
||||
audio: [],
|
||||
},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(28);
|
||||
|
||||
const metadata = result.project.metadata as Record<string, unknown>;
|
||||
expect(metadata.duration).toBe(2_152_466);
|
||||
|
||||
const timelineViewState = result.project.timelineViewState as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(timelineViewState.playheadTime).toBe(301_235);
|
||||
expect(timelineViewState.zoomLevel).toBe(1.25);
|
||||
expect(timelineViewState.scrollLeft).toBe(120);
|
||||
|
||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
||||
const scene = scenes[0];
|
||||
expect(scene.bookmarks).toEqual([
|
||||
{
|
||||
time: 120_000,
|
||||
duration: 60_001,
|
||||
note: "Marker",
|
||||
color: "#ff0000",
|
||||
},
|
||||
]);
|
||||
|
||||
const tracks = scene.tracks as Record<string, unknown>;
|
||||
const mainTrack = tracks.main as Record<string, unknown>;
|
||||
const elements = mainTrack.elements as Array<Record<string, unknown>>;
|
||||
const element = elements[0];
|
||||
expect(element.startTime).toBe(300_000);
|
||||
expect(element.duration).toBe(2_152_466);
|
||||
expect(element.trimStart).toBe(30_000);
|
||||
expect(element.trimEnd).toBe(15_001);
|
||||
expect(element.sourceDuration).toBe(2_197_468);
|
||||
|
||||
const animations = element.animations as Record<string, unknown>;
|
||||
const channels = animations.channels as Record<string, Record<string, unknown>>;
|
||||
const opacityChannel = channels.opacity;
|
||||
expect(opacityChannel.keys).toEqual([
|
||||
{
|
||||
id: "key-1",
|
||||
time: 1_001,
|
||||
value: 1,
|
||||
segmentToNext: "bezier",
|
||||
tangentMode: "flat",
|
||||
leftHandle: { dt: -121, dv: 0 },
|
||||
rightHandle: { dt: 60, dv: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps already-integer media-time values unchanged", () => {
|
||||
const result = transformProjectV27ToV28({
|
||||
project: {
|
||||
id: "project-v27-integer-time",
|
||||
version: 27,
|
||||
metadata: {
|
||||
id: "project-v27-integer-time",
|
||||
name: "Project",
|
||||
duration: 120_000,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
timelineViewState: {
|
||||
zoomLevel: 2,
|
||||
scrollLeft: 300,
|
||||
playheadTime: 30_000,
|
||||
},
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-1",
|
||||
bookmarks: [{ time: 60_000, duration: 15_000 }],
|
||||
tracks: {
|
||||
main: {
|
||||
id: "main-track",
|
||||
type: "video",
|
||||
name: "Main",
|
||||
muted: false,
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "element-1",
|
||||
type: "video",
|
||||
name: "Clip",
|
||||
startTime: 10_000,
|
||||
duration: 20_000,
|
||||
trimStart: 1_000,
|
||||
trimEnd: 2_000,
|
||||
sourceDuration: 23_000,
|
||||
mediaId: "media-1",
|
||||
transform: {
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
overlay: [],
|
||||
audio: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(28);
|
||||
|
||||
const metadata = result.project.metadata as Record<string, unknown>;
|
||||
expect(metadata.duration).toBe(120_000);
|
||||
|
||||
const timelineViewState = result.project.timelineViewState as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(timelineViewState).toEqual({
|
||||
zoomLevel: 2,
|
||||
scrollLeft: 300,
|
||||
playheadTime: 30_000,
|
||||
});
|
||||
|
||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
||||
const scene = scenes[0];
|
||||
expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]);
|
||||
|
||||
const tracks = scene.tracks as Record<string, unknown>;
|
||||
const mainTrack = tracks.main as Record<string, unknown>;
|
||||
const element = (mainTrack.elements as Array<Record<string, unknown>>)[0];
|
||||
expect(element.startTime).toBe(10_000);
|
||||
expect(element.duration).toBe(20_000);
|
||||
expect(element.trimStart).toBe(1_000);
|
||||
expect(element.trimEnd).toBe(2_000);
|
||||
expect(element.sourceDuration).toBe(23_000);
|
||||
});
|
||||
|
||||
test("skips projects already on v28", () => {
|
||||
const result = transformProjectV27ToV28({
|
||||
project: {
|
||||
id: "project-v28",
|
||||
version: 28,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v28");
|
||||
});
|
||||
|
||||
test("skips projects not on v27", () => {
|
||||
const result = transformProjectV27ToV28({
|
||||
project: {
|
||||
id: "project-v26",
|
||||
version: 26,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("not v27");
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,11 @@ import { V23toV24Migration } from "./v23-to-v24";
|
||||
import { V24toV25Migration } from "./v24-to-v25";
|
||||
import { V25toV26Migration } from "./v25-to-v26";
|
||||
import { V26toV27Migration } from "./v26-to-v27";
|
||||
import { V27toV28Migration } from "./v27-to-v28";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 27;
|
||||
export const CURRENT_PROJECT_VERSION = 28;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
@@ -59,4 +60,5 @@ export const migrations = [
|
||||
new V24toV25Migration(),
|
||||
new V25toV26Migration(),
|
||||
new V26toV27Migration(),
|
||||
new V27toV28Migration(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
export function transformProjectV27ToV28({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
if (!getProjectId({ project })) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
const version = project.version;
|
||||
if (typeof version !== "number") {
|
||||
return { project, skipped: true, reason: "invalid version" };
|
||||
}
|
||||
if (version >= 28) {
|
||||
return { project, skipped: true, reason: "already v28" };
|
||||
}
|
||||
if (version !== 27) {
|
||||
return { project, skipped: true, reason: "not v27" };
|
||||
}
|
||||
|
||||
return {
|
||||
project: {
|
||||
...migrateProject({ project }),
|
||||
version: 28,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProject({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const nextProject = { ...project };
|
||||
|
||||
if (isRecord(project.metadata)) {
|
||||
nextProject.metadata = migrateTimeFields({
|
||||
record: project.metadata,
|
||||
keys: ["duration"],
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(project.timelineViewState)) {
|
||||
nextProject.timelineViewState = migrateTimeFields({
|
||||
record: project.timelineViewState,
|
||||
keys: ["playheadTime"],
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(project.scenes)) {
|
||||
nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
|
||||
}
|
||||
|
||||
return nextProject;
|
||||
}
|
||||
|
||||
function migrateScene({ scene }: { scene: unknown }): unknown {
|
||||
if (!isRecord(scene)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
const nextScene = { ...scene };
|
||||
|
||||
if (Array.isArray(scene.bookmarks)) {
|
||||
nextScene.bookmarks = scene.bookmarks.map((bookmark) =>
|
||||
migrateBookmark({ bookmark }),
|
||||
);
|
||||
}
|
||||
|
||||
if (isRecord(scene.tracks)) {
|
||||
nextScene.tracks = migrateTracks({ tracks: scene.tracks });
|
||||
}
|
||||
|
||||
return nextScene;
|
||||
}
|
||||
|
||||
function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
|
||||
const nextTracks = { ...tracks };
|
||||
|
||||
if (isRecord(tracks.main)) {
|
||||
nextTracks.main = migrateTrack({ track: tracks.main });
|
||||
}
|
||||
|
||||
if (Array.isArray(tracks.overlay)) {
|
||||
nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
|
||||
}
|
||||
|
||||
if (Array.isArray(tracks.audio)) {
|
||||
nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
|
||||
}
|
||||
|
||||
return nextTracks;
|
||||
}
|
||||
|
||||
function migrateTrack({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track) || !Array.isArray(track.elements)) {
|
||||
return track;
|
||||
}
|
||||
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((element) => migrateElement({ element })),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateElement({ element }: { element: unknown }): unknown {
|
||||
if (!isRecord(element)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const nextElement = migrateTimeFields({
|
||||
record: element,
|
||||
keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"],
|
||||
});
|
||||
|
||||
if (isRecord(element.animations)) {
|
||||
nextElement.animations = migrateAnimations({
|
||||
animations: element.animations,
|
||||
});
|
||||
}
|
||||
|
||||
return nextElement;
|
||||
}
|
||||
|
||||
function migrateAnimations({
|
||||
animations,
|
||||
}: {
|
||||
animations: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
if (!isRecord(animations.channels)) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
return {
|
||||
...animations,
|
||||
channels: Object.fromEntries(
|
||||
Object.entries(animations.channels).map(([channelId, channel]) => [
|
||||
channelId,
|
||||
migrateAnimationChannel({ channel }),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateAnimationChannel({ channel }: { channel: unknown }): unknown {
|
||||
if (!isRecord(channel) || !Array.isArray(channel.keys)) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
return {
|
||||
...channel,
|
||||
keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateAnimationKeyframe({
|
||||
keyframe,
|
||||
}: {
|
||||
keyframe: unknown;
|
||||
}): unknown {
|
||||
if (!isRecord(keyframe)) {
|
||||
return keyframe;
|
||||
}
|
||||
|
||||
const nextKeyframe = migrateTimeFields({
|
||||
record: keyframe,
|
||||
keys: ["time"],
|
||||
});
|
||||
|
||||
if (isRecord(keyframe.leftHandle)) {
|
||||
nextKeyframe.leftHandle = migrateTimeFields({
|
||||
record: keyframe.leftHandle,
|
||||
keys: ["dt"],
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(keyframe.rightHandle)) {
|
||||
nextKeyframe.rightHandle = migrateTimeFields({
|
||||
record: keyframe.rightHandle,
|
||||
keys: ["dt"],
|
||||
});
|
||||
}
|
||||
|
||||
return nextKeyframe;
|
||||
}
|
||||
|
||||
function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown {
|
||||
if (!isRecord(bookmark)) {
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
return migrateTimeFields({
|
||||
record: bookmark,
|
||||
keys: ["time", "duration"],
|
||||
});
|
||||
}
|
||||
|
||||
function migrateTimeFields({
|
||||
record,
|
||||
keys,
|
||||
}: {
|
||||
record: ProjectRecord;
|
||||
keys: string[];
|
||||
}): ProjectRecord {
|
||||
const nextRecord = { ...record };
|
||||
|
||||
for (const key of keys) {
|
||||
if (!(key in record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextRecord[key] = normalizeMediaTimeValue({ value: record[key] });
|
||||
}
|
||||
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
function normalizeMediaTimeValue({ value }: { value: unknown }): unknown {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return roundMediaTime({ time: value });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StorageMigration, type StorageMigrationRunArgs } from "./base";
|
||||
import type { MigrationResult, ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV27ToV28 } from "./transformers/v27-to-v28";
|
||||
|
||||
export class V27toV28Migration extends StorageMigration {
|
||||
from = 27;
|
||||
to = 28;
|
||||
|
||||
async run({
|
||||
project,
|
||||
}: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
|
||||
return transformProjectV27ToV28({ project });
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,15 @@ import {
|
||||
runStorageMigrations,
|
||||
} from "@/services/storage/migrations";
|
||||
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
|
||||
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((item): Bookmark | null => {
|
||||
if (typeof item === "number") return { time: item };
|
||||
if (typeof item === "number") {
|
||||
return { time: roundMediaTime({ time: item }) };
|
||||
}
|
||||
const obj = item as Record<string, unknown>;
|
||||
if (
|
||||
typeof obj !== "object" ||
|
||||
@@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
time: obj.time,
|
||||
time: roundMediaTime({ time: obj.time }),
|
||||
...(typeof obj.note === "string" && { note: obj.note }),
|
||||
...(typeof obj.color === "string" && { color: obj.color }),
|
||||
...(typeof obj.duration === "number" && { duration: obj.duration }),
|
||||
...(typeof obj.duration === "number" && {
|
||||
duration: roundMediaTime({ time: obj.duration }),
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((b): b is Bookmark => b !== null);
|
||||
@@ -198,9 +203,11 @@ class StorageService {
|
||||
id: serializedProject.metadata.id,
|
||||
name: serializedProject.metadata.name,
|
||||
thumbnail: serializedProject.metadata.thumbnail,
|
||||
duration:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({ scenes }),
|
||||
duration: roundMediaTime({
|
||||
time:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({ scenes }),
|
||||
}),
|
||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||
},
|
||||
@@ -253,11 +260,13 @@ class StorageService {
|
||||
id: serializedProject.metadata.id,
|
||||
name: serializedProject.metadata.name,
|
||||
thumbnail: serializedProject.metadata.thumbnail,
|
||||
duration:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({
|
||||
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
|
||||
}),
|
||||
duration: roundMediaTime({
|
||||
time:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({
|
||||
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
|
||||
}),
|
||||
}),
|
||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { storageService } from "@/services/storage/service";
|
||||
import { toast } from "sonner";
|
||||
import { EditorCore } from "@/core";
|
||||
import { buildLibraryAudioElement } from "@/timeline/element-utils";
|
||||
import { mediaTimeFromSeconds } from "@/wasm";
|
||||
|
||||
interface SoundsStore {
|
||||
topSoundEffects: SoundEffect[];
|
||||
@@ -227,7 +228,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||
const element = buildLibraryAudioElement({
|
||||
sourceUrl: audioUrl,
|
||||
name: sound.name,
|
||||
duration: sound.duration,
|
||||
duration: mediaTimeFromSeconds({ seconds: sound.duration }),
|
||||
startTime: currentTime,
|
||||
buffer,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
setCanvasLetterSpacing,
|
||||
} from "@/text/layout";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { mediaTimeFromSeconds } from "@/wasm";
|
||||
import type { CreateTextElement } from "@/timeline";
|
||||
import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
|
||||
|
||||
@@ -310,8 +310,8 @@ export function buildSubtitleTextElement({
|
||||
...DEFAULTS.text.element,
|
||||
name: `Caption ${index + 1}`,
|
||||
content,
|
||||
duration: Math.round(caption.duration * TICKS_PER_SECOND),
|
||||
startTime: Math.round(caption.startTime * TICKS_PER_SECOND),
|
||||
duration: mediaTimeFromSeconds({ seconds: caption.duration }),
|
||||
startTime: mediaTimeFromSeconds({ seconds: caption.startTime }),
|
||||
fontSize: style.fontSize,
|
||||
fontFamily: style.fontFamily,
|
||||
color: style.color,
|
||||
|
||||
@@ -3,11 +3,12 @@ import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { buildTextElement } from "@/timeline/element-utils";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export function TextView() {
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
|
||||
const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => {
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
if (!activeScene) return;
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ import { useKeyframedNumberProperty } from "@/components/editor/panels/propertie
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
|
||||
import { resolveColorAtTime, resolveNumberAtTime } from "@/animation";
|
||||
import {
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
} from "@/animation/values";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
MinusSignIcon,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CORNER_RADIUS_MIN } from "@/text/background";
|
||||
import { resolveNumberAtTime } from "@/animation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import type { TextBackground, TextElement } from "@/timeline";
|
||||
import { resolveNumberAtTime } from "@/animation/values";
|
||||
import {
|
||||
getTextVisualRect,
|
||||
} from "./layout";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { SceneTracks, VideoElement } from "@/timeline";
|
||||
import { applyElementUpdate } from "@/timeline/update-pipeline";
|
||||
import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||
|
||||
function buildTransform(): Transform {
|
||||
return {
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement {
|
||||
return {
|
||||
id: "video-1",
|
||||
type: "video",
|
||||
name: "Video 1",
|
||||
startTime: ZERO_MEDIA_TIME,
|
||||
duration: mediaTime({ ticks: 10 }),
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
mediaId: "media-1",
|
||||
transform: buildTransform(),
|
||||
opacity: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTracks(element: VideoElement): SceneTracks {
|
||||
return {
|
||||
overlay: [],
|
||||
main: {
|
||||
id: "main-track",
|
||||
type: "video",
|
||||
name: "Main",
|
||||
muted: false,
|
||||
hidden: false,
|
||||
elements: [element],
|
||||
},
|
||||
audio: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyElementUpdate", () => {
|
||||
test("rounds retimed durations back to integer media time", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks(element);
|
||||
|
||||
const updatedElement = applyElementUpdate({
|
||||
element,
|
||||
patch: {
|
||||
retime: { rate: 1.5 },
|
||||
},
|
||||
context: {
|
||||
tracks,
|
||||
trackId: tracks.main.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedElement.duration).toBe(7);
|
||||
expect(Number.isInteger(updatedElement.duration)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
import type {
|
||||
AnimationBindingKind,
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
ElementAnimations,
|
||||
NumericSpec,
|
||||
} from "@/animation/types";
|
||||
import { upsertPathKeyframe } from "@/animation";
|
||||
import { parseColorToLinearRgba } from "@/animation/binding-values";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
import { isAnimationPropertyPath } from "@/animation/path";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
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";
|
||||
|
||||
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 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 });
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { getElementKeyframes } from "@/animation";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import { addMediaTime } from "@/wasm";
|
||||
|
||||
export function getAnimationKeyframeSnapPointsForTimeline({
|
||||
tracks,
|
||||
@@ -22,7 +23,7 @@ export function getAnimationKeyframeSnapPointsForTimeline({
|
||||
animations: element.animations,
|
||||
})) {
|
||||
snapPoints.push({
|
||||
time: element.startTime + keyframe.time,
|
||||
time: addMediaTime({ a: element.startTime, b: keyframe.time }),
|
||||
type: "keyframe",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
+26
-97
@@ -3,30 +3,32 @@ import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
NumericSpec,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamNumericRange,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
import {
|
||||
parseEffectParamPath,
|
||||
isEffectParamPath,
|
||||
} from "@/animation/effect-param-channel";
|
||||
import {
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
} from "@/animation/graphic-param-channel";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import { effectsRegistry, registerDefaultEffects } from "@/effects";
|
||||
import { getGraphicDefinition } from "@/graphics";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import { isAnimationPropertyPath } from "@/animation/path";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
import { parseColorToLinearRgba } from "./binding-values";
|
||||
} from "./animation-properties";
|
||||
|
||||
export interface AnimationPathDescriptor {
|
||||
kind: AnimationBindingKind;
|
||||
@@ -37,79 +39,16 @@ export interface AnimationPathDescriptor {
|
||||
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
|
||||
}
|
||||
|
||||
export function getParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationBindingKind {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationInterpolation {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
function getParamNumericRange({
|
||||
// Number/discrete bindings expose a single component named "value"
|
||||
// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry
|
||||
// numeric ranges yet — revisit when one does.
|
||||
function paramNumericRanges({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForParam({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: AnimationValue;
|
||||
}): number | string | boolean | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const minValue = param.min;
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
const range = getAnimationParamNumericRange({ param });
|
||||
return range ? { value: range } : undefined;
|
||||
}
|
||||
|
||||
function buildGraphicParamDescriptor({
|
||||
@@ -132,13 +71,13 @@ function buildGraphicParamDescriptor({
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||
kind: getAnimationParamValueKind({ param }),
|
||||
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||
numericRanges: paramNumericRanges({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
@@ -180,13 +119,13 @@ function buildEffectParamDescriptor({
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||
kind: getAnimationParamValueKind({ param }),
|
||||
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||
numericRanges: paramNumericRanges({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
@@ -210,16 +149,6 @@ function buildEffectParamDescriptor({
|
||||
};
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAnimationTarget({
|
||||
element,
|
||||
path,
|
||||
@@ -1,5 +1,5 @@
|
||||
import { hasKeyframesForPath } from "@/animation/keyframe-query";
|
||||
import { resolveNumberAtTime } from "@/animation/resolve";
|
||||
import { resolveNumberAtTime } from "@/animation/values";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
|
||||
import type { TimelineElement } from "./types";
|
||||
const DEFAULT_STEP_SECONDS = 1 / 60;
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { BookmarkDragState } from "../hooks/use-bookmark-drag";
|
||||
import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme";
|
||||
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout";
|
||||
import { DEFAULT_FPS } from "@/fps/defaults";
|
||||
import { snappedSeekTime } from "opencut-wasm";
|
||||
import {
|
||||
ArrowTurnBackwardIcon,
|
||||
Delete02Icon,
|
||||
@@ -26,6 +25,14 @@ import { Label } from "@/components/ui/label";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp, formatNumberForDisplay } from "@/utils/math";
|
||||
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
|
||||
import {
|
||||
type MediaTime,
|
||||
mediaTimeFromSeconds,
|
||||
mediaTimeToSeconds,
|
||||
snapSeekMediaTime,
|
||||
subMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
const MIN_BOOKMARK_WIDTH_PX = 2;
|
||||
const BOOKMARK_MARKER_WIDTH_PX = 12;
|
||||
@@ -39,12 +46,12 @@ function seekToBookmarkTime({
|
||||
time,
|
||||
}: {
|
||||
editor: EditorCore;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}) {
|
||||
const activeProject = editor.project.getActive();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
|
||||
const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
|
||||
const snappedTime = snapSeekMediaTime({ time, duration, fps: rate });
|
||||
editor.playback.seek({ time: snappedTime });
|
||||
}
|
||||
|
||||
@@ -137,7 +144,7 @@ function TimelineBookmark({
|
||||
|
||||
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
|
||||
const time = bookmark.time;
|
||||
const bookmarkDuration = bookmark.duration ?? 0;
|
||||
const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME;
|
||||
const durationWidth =
|
||||
bookmarkDuration > 0
|
||||
? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
|
||||
@@ -182,7 +189,7 @@ function TimelineBookmark({
|
||||
left: `${bookmarkLeft}px`,
|
||||
width: `${bookmarkWidth}px`,
|
||||
}}
|
||||
aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
|
||||
aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
@@ -286,8 +293,8 @@ function BookmarkPopoverContent({
|
||||
onPopoverClose,
|
||||
}: {
|
||||
bookmark: Bookmark;
|
||||
time: number;
|
||||
timelineDuration: number;
|
||||
time: MediaTime;
|
||||
timelineDuration: MediaTime;
|
||||
onPopoverClose: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
@@ -314,9 +321,8 @@ function BookmarkPopoverContent({
|
||||
note,
|
||||
color,
|
||||
duration,
|
||||
}: Partial<{ note: string; color: string; duration: number }>) => {
|
||||
const updates: Partial<{ note: string; color: string; duration: number }> =
|
||||
{};
|
||||
}: Partial<Omit<Bookmark, "time">>) => {
|
||||
const updates: Partial<Omit<Bookmark, "time">> = {};
|
||||
if (note !== undefined && note !== bookmark.note) updates.note = note;
|
||||
if (
|
||||
color !== undefined &&
|
||||
@@ -330,6 +336,15 @@ function BookmarkPopoverContent({
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
editor.scenes.updateBookmark({ time, updates });
|
||||
};
|
||||
const maxDuration = mediaTimeToSeconds({
|
||||
time:
|
||||
timelineDuration > time
|
||||
? subMediaTime({ a: timelineDuration, b: time })
|
||||
: ZERO_MEDIA_TIME,
|
||||
});
|
||||
const durationSeconds = mediaTimeToSeconds({
|
||||
time: bookmark.duration ?? ZERO_MEDIA_TIME,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -387,7 +402,7 @@ function BookmarkPopoverContent({
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={bookmark.duration ?? 0}
|
||||
value={durationSeconds}
|
||||
onChange={(event) => {
|
||||
const parsed = parseFloat(event.target.value);
|
||||
const value = Number.isNaN(parsed)
|
||||
@@ -395,9 +410,11 @@ function BookmarkPopoverContent({
|
||||
: clamp({
|
||||
value: parsed,
|
||||
min: 0,
|
||||
max: Math.max(0, timelineDuration - time),
|
||||
max: maxDuration,
|
||||
});
|
||||
handleUpdate({ duration: value });
|
||||
handleUpdate({
|
||||
duration: mediaTimeFromSeconds({ seconds: value }),
|
||||
});
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
containerClassName="w-full"
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
|
||||
import {
|
||||
buildTimelineSnapPoints,
|
||||
@@ -19,17 +18,18 @@ import {
|
||||
import { getBookmarkSnapPoints } from "../snap-source";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||
import type { Bookmark } from "@/timeline";
|
||||
import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||
|
||||
export interface BookmarkDragState {
|
||||
isDragging: boolean;
|
||||
bookmarkTime: number | null;
|
||||
currentTime: number;
|
||||
bookmarkTime: MediaTime | null;
|
||||
currentTime: MediaTime;
|
||||
}
|
||||
|
||||
interface PendingBookmarkDrag {
|
||||
bookmarkTime: number;
|
||||
bookmarkTime: MediaTime;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function useBookmarkDrag({
|
||||
const [dragState, setDragState] = useState<BookmarkDragState>({
|
||||
isDragging: false,
|
||||
bookmarkTime: null,
|
||||
currentTime: 0,
|
||||
currentTime: ZERO_MEDIA_TIME,
|
||||
});
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
|
||||
@@ -69,8 +69,8 @@ export function useBookmarkDrag({
|
||||
bookmarkTime,
|
||||
initialCurrentTime,
|
||||
}: {
|
||||
bookmarkTime: number;
|
||||
initialCurrentTime: number;
|
||||
bookmarkTime: MediaTime;
|
||||
initialCurrentTime: MediaTime;
|
||||
}) => {
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
@@ -85,7 +85,7 @@ export function useBookmarkDrag({
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
bookmarkTime: null,
|
||||
currentTime: 0,
|
||||
currentTime: ZERO_MEDIA_TIME,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -94,9 +94,9 @@ export function useBookmarkDrag({
|
||||
rawTime,
|
||||
excludeBookmarkTime,
|
||||
}: {
|
||||
rawTime: number;
|
||||
excludeBookmarkTime: number;
|
||||
}): { snappedTime: number; snapPoint: SnapPoint | null } => {
|
||||
rawTime: MediaTime;
|
||||
excludeBookmarkTime: MediaTime;
|
||||
}): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => {
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
if (!shouldSnap) {
|
||||
return { snappedTime: rawTime, snapPoint: null };
|
||||
@@ -162,11 +162,12 @@ export function useBookmarkDrag({
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const frameSnappedTime =
|
||||
roundToFrame({
|
||||
time: Math.max(0, Math.min(mouseTime, duration)),
|
||||
rate: activeProject.settings.fps,
|
||||
}) ?? Math.max(0, Math.min(mouseTime, duration));
|
||||
const clampedTime =
|
||||
mouseTime > duration ? duration : mouseTime;
|
||||
const frameSnappedTime = roundFrameTime({
|
||||
time: clampedTime,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const { snappedTime: initialTime } = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: bookmarkTime,
|
||||
@@ -193,10 +194,12 @@ export function useBookmarkDrag({
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
|
||||
const frameSnappedTime =
|
||||
roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ??
|
||||
clampedTime;
|
||||
const clampedTime =
|
||||
mouseTime > duration ? duration : mouseTime;
|
||||
const frameSnappedTime = roundFrameTime({
|
||||
time: clampedTime,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const snapResult = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: dragState.bookmarkTime,
|
||||
@@ -234,10 +237,8 @@ export function useBookmarkDrag({
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(dragState.currentTime, duration),
|
||||
);
|
||||
const clampedTime =
|
||||
dragState.currentTime > duration ? duration : dragState.currentTime;
|
||||
|
||||
editor.scenes.moveBookmark({
|
||||
fromTime: dragState.bookmarkTime,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type PreviewOverlaySourceResult,
|
||||
} from "@/preview/overlays";
|
||||
import { getBookmarksActiveAtTime } from "./utils";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
|
||||
id: "bookmark-notes",
|
||||
@@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
|
||||
function BookmarkNotesOverlay({
|
||||
bookmarks,
|
||||
}: {
|
||||
bookmarks: Array<{ time: number; note: string; color?: string }>;
|
||||
bookmarks: Array<{ time: MediaTime; note: string; color?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" aria-live="polite">
|
||||
@@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({
|
||||
isVisible,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
isVisible: boolean;
|
||||
}): PreviewOverlaySourceResult {
|
||||
const bookmarksWithNotes = getBookmarksActiveAtTime({
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Bookmark } from "@/timeline";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export function getBookmarkSnapPoints({
|
||||
bookmarks,
|
||||
excludeBookmarkTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
excludeBookmarkTime?: number;
|
||||
excludeBookmarkTime?: MediaTime;
|
||||
}): SnapPoint[] {
|
||||
return bookmarks.flatMap((bookmark) => {
|
||||
if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Bookmark } from "@/timeline";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import { addMediaTime, roundFrameTime, type MediaTime } from "@/wasm";
|
||||
|
||||
function bookmarkTimeEqual({
|
||||
bookmarkTime,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarkTime: number;
|
||||
frameTime: number;
|
||||
bookmarkTime: MediaTime;
|
||||
frameTime: MediaTime;
|
||||
}): boolean {
|
||||
return bookmarkTime === frameTime;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function findBookmarkIndex({
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
frameTime: MediaTime;
|
||||
}): number {
|
||||
return bookmarks.findIndex((bookmark) =>
|
||||
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||
@@ -29,7 +29,7 @@ export function isBookmarkAtTime({
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
frameTime: MediaTime;
|
||||
}): boolean {
|
||||
return bookmarks.some((bookmark) =>
|
||||
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||
@@ -41,7 +41,7 @@ export function toggleBookmarkInArray({
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
frameTime: MediaTime;
|
||||
}): Bookmark[] {
|
||||
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
|
||||
|
||||
@@ -58,7 +58,7 @@ export function removeBookmarkFromArray({
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
frameTime: MediaTime;
|
||||
}): Bookmark[] {
|
||||
return bookmarks.filter(
|
||||
(bookmark) =>
|
||||
@@ -72,7 +72,7 @@ export function updateBookmarkInArray({
|
||||
updates,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
frameTime: MediaTime;
|
||||
updates: Partial<Omit<Bookmark, "time">>;
|
||||
}): Bookmark[] {
|
||||
const index = findBookmarkIndex({ bookmarks, frameTime });
|
||||
@@ -92,8 +92,8 @@ export function moveBookmarkInArray({
|
||||
toTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
fromTime: number;
|
||||
toTime: number;
|
||||
fromTime: MediaTime;
|
||||
toTime: MediaTime;
|
||||
}): Bookmark[] {
|
||||
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
|
||||
if (index === -1) {
|
||||
@@ -110,10 +110,10 @@ export function getFrameTime({
|
||||
time,
|
||||
fps,
|
||||
}: {
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
fps: FrameRate;
|
||||
}): number {
|
||||
return roundToFrame({ time, rate: fps }) ?? time;
|
||||
}): MediaTime {
|
||||
return roundFrameTime({ time, fps });
|
||||
}
|
||||
|
||||
export function getBookmarkAtTime({
|
||||
@@ -121,7 +121,7 @@ export function getBookmarkAtTime({
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
frameTime: MediaTime;
|
||||
}): Bookmark | null {
|
||||
const index = findBookmarkIndex({ bookmarks, frameTime });
|
||||
return index === -1 ? null : bookmarks[index];
|
||||
@@ -132,13 +132,13 @@ export function getBookmarksActiveAtTime({
|
||||
time,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}): Bookmark[] {
|
||||
return bookmarks.filter((bookmark) => {
|
||||
const start = bookmark.time;
|
||||
const end =
|
||||
bookmark.duration != null && bookmark.duration > 0
|
||||
? start + bookmark.duration
|
||||
? addMediaTime({ a: start, b: bookmark.duration })
|
||||
: start;
|
||||
return time >= start && time <= end;
|
||||
});
|
||||
|
||||
@@ -1,151 +1,151 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import {
|
||||
clamp,
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
isNearlyEqual,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
import type { AudioElement, VideoElement } from "@/timeline";
|
||||
import { resolveNumberAtTime } from "@/animation";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
|
||||
const VOLUME_STEP = 0.1;
|
||||
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
||||
|
||||
export function AudioTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedVolume = resolveNumberAtTime({
|
||||
baseValue: element.volume ?? DEFAULTS.element.volume,
|
||||
animations: element.animations,
|
||||
propertyPath: "volume",
|
||||
localTime,
|
||||
});
|
||||
const volume = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "volume",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: formatNumberForDisplay({
|
||||
value: resolvedVolume,
|
||||
fractionDigits: VOLUME_FRACTION_DIGITS,
|
||||
}),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clamp({
|
||||
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
|
||||
min: VOLUME_DB_MIN,
|
||||
max: VOLUME_DB_MAX,
|
||||
});
|
||||
},
|
||||
valueAtPlayhead: resolvedVolume,
|
||||
step: VOLUME_STEP,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
volume: value,
|
||||
}),
|
||||
});
|
||||
const isDefault =
|
||||
volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
|
||||
? isNearlyEqual({
|
||||
leftValue: resolvedVolume,
|
||||
rightValue: DEFAULTS.element.volume,
|
||||
})
|
||||
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
||||
const isSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSeparated && (
|
||||
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
||||
<p className="text-sm">Audio has been separated.</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Recover audio
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
}
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import {
|
||||
clamp,
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
isNearlyEqual,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
import type { AudioElement, VideoElement } from "@/timeline";
|
||||
import { resolveNumberAtTime } from "@/animation/values";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
|
||||
const VOLUME_STEP = 0.1;
|
||||
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
||||
|
||||
export function AudioTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedVolume = resolveNumberAtTime({
|
||||
baseValue: element.volume ?? DEFAULTS.element.volume,
|
||||
animations: element.animations,
|
||||
propertyPath: "volume",
|
||||
localTime,
|
||||
});
|
||||
const volume = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "volume",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: formatNumberForDisplay({
|
||||
value: resolvedVolume,
|
||||
fractionDigits: VOLUME_FRACTION_DIGITS,
|
||||
}),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clamp({
|
||||
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
|
||||
min: VOLUME_DB_MIN,
|
||||
max: VOLUME_DB_MAX,
|
||||
});
|
||||
},
|
||||
valueAtPlayhead: resolvedVolume,
|
||||
step: VOLUME_STEP,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
volume: value,
|
||||
}),
|
||||
});
|
||||
const isDefault =
|
||||
volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
|
||||
? isNearlyEqual({
|
||||
leftValue: resolvedVolume,
|
||||
rightValue: DEFAULTS.element.volume,
|
||||
})
|
||||
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
||||
const isSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSeparated && (
|
||||
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
||||
<p className="text-sm">Audio has been separated.</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Recover audio
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
}
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
|
||||
import { resolveTrackPlacement } from "@/timeline/placement";
|
||||
import { TIMELINE_TRACK_GAP_PX } from "./layout";
|
||||
import { getTrackHeight } from "./track-layout";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
mediaTime,
|
||||
type MediaTime,
|
||||
roundMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
} from "@/wasm";
|
||||
|
||||
function findElementAtPosition({
|
||||
mouseX,
|
||||
@@ -20,9 +25,11 @@ function findElementAtPosition({
|
||||
pixelsPerSecond: number;
|
||||
zoomLevel: number;
|
||||
}): { elementId: string; trackId: string } | null {
|
||||
const time = Math.round(
|
||||
(mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
|
||||
);
|
||||
const time = mediaTime({
|
||||
ticks: Math.round(
|
||||
(mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
|
||||
),
|
||||
});
|
||||
const track = tracks[trackIndex];
|
||||
if (!track || !("elements" in track)) return null;
|
||||
|
||||
@@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null;
|
||||
function fallbackNewTrackDropTarget({
|
||||
xPosition,
|
||||
}: {
|
||||
xPosition: number;
|
||||
xPosition: MediaTime;
|
||||
}): DropTarget {
|
||||
return {
|
||||
trackIndex: 0,
|
||||
@@ -111,13 +118,16 @@ export function computeDropTarget({
|
||||
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||
const mainTrackIndex = tracks.overlay.length;
|
||||
const xPosition =
|
||||
typeof startTimeOverride === "number"
|
||||
startTimeOverride !== undefined
|
||||
? startTimeOverride
|
||||
: isExternalDrop
|
||||
? playheadTime
|
||||
: Math.round(
|
||||
Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
|
||||
);
|
||||
: mediaTime({
|
||||
ticks: Math.round(
|
||||
Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) *
|
||||
TICKS_PER_SECOND,
|
||||
),
|
||||
});
|
||||
|
||||
if (orderedTracks.length === 0) {
|
||||
const placementResult = resolveTrackPlacement({
|
||||
@@ -221,7 +231,10 @@ export function computeDropTarget({
|
||||
}
|
||||
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
|
||||
const adjustedXPosition =
|
||||
placementResult.adjustedStartTime !== undefined
|
||||
? roundMediaTime({ time: placementResult.adjustedStartTime })
|
||||
: xPosition;
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.trackIndex,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
import type { ElementDragView, DropTarget } from "@/timeline";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import { TimelinePlayhead } from "./timeline-playhead";
|
||||
@@ -132,7 +133,7 @@ export function Timeline() {
|
||||
[scene],
|
||||
);
|
||||
const mainTrackId = scene?.tracks.main.id ?? null;
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
const seek = (time: MediaTime) => editor.playback.seek({ time });
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const timelineHeaderRef = useRef<HTMLDivElement>(null);
|
||||
@@ -289,12 +290,12 @@ export function Timeline() {
|
||||
|
||||
const { dragView, handleElementMouseDown, handleElementClick } =
|
||||
useElementInteraction({
|
||||
zoomLevel,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
zoomLevel,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
const isElementDragging = dragView.kind === "dragging";
|
||||
|
||||
const {
|
||||
@@ -777,8 +778,8 @@ function TimelineTrackRows({
|
||||
const draggingElementIds = useMemo(
|
||||
() =>
|
||||
dragView.kind === "dragging"
|
||||
? dragView.memberTimeOffsets
|
||||
: (null as ReadonlyMap<string, number> | null),
|
||||
? dragView.memberTimeOffsets
|
||||
: (null as ReadonlyMap<string, MediaTime> | null),
|
||||
[dragView],
|
||||
);
|
||||
const sortedTracks = useMemo(() => {
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import { buildWaveformGainSamples } from "@/timeline/audio-state";
|
||||
import { getTimelinePixelsPerSecond } from "@/timeline";
|
||||
import { buildWaveformSourceKey } from "@/media/waveform-summary";
|
||||
import { TICKS_PER_SECOND } from "@/wasm/ticks";
|
||||
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
getActionDefinition,
|
||||
type TAction,
|
||||
@@ -96,7 +96,7 @@ const PixelsPerSecondContext = createContext<number | null>(null);
|
||||
const THUMBNAIL_ASPECT_RATIO = 16 / 9;
|
||||
|
||||
interface KeyframeIndicator {
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
offsetPx: number;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}
|
||||
@@ -112,11 +112,11 @@ export function buildKeyframeIndicator({
|
||||
keyframe: ElementKeyframe;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
displayedStartTime: number;
|
||||
displayedStartTime: MediaTime;
|
||||
zoomLevel: number;
|
||||
elementLeft: number;
|
||||
}): {
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
offsetPx: number;
|
||||
keyframeRef: SelectedKeyframeRef;
|
||||
} {
|
||||
@@ -149,7 +149,7 @@ export function getKeyframeIndicators({
|
||||
keyframes: ElementKeyframe[];
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
displayedStartTime: number;
|
||||
displayedStartTime: MediaTime;
|
||||
zoomLevel: number;
|
||||
elementLeft: number;
|
||||
elementWidth: number;
|
||||
@@ -158,7 +158,7 @@ export function getKeyframeIndicators({
|
||||
return [];
|
||||
}
|
||||
|
||||
const keyframesByTime = new Map<number, KeyframeIndicator>();
|
||||
const keyframesByTime = new Map<MediaTime, KeyframeIndicator>();
|
||||
for (const keyframe of keyframes) {
|
||||
const indicator = buildKeyframeIndicator({
|
||||
keyframe,
|
||||
@@ -263,7 +263,7 @@ export function TimelineElement({
|
||||
: 0;
|
||||
const elementStartTime =
|
||||
isDragging && isBeingDragged
|
||||
? dragView.currentTime + dragTimeOffset
|
||||
? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset })
|
||||
: renderElement.startTime;
|
||||
const displayedStartTime = elementStartTime;
|
||||
const displayedDuration = renderElement.duration;
|
||||
@@ -385,7 +385,7 @@ export function TimelineElement({
|
||||
? `${baseTrackHeight + expansionHeight}px`
|
||||
: "100%",
|
||||
transform:
|
||||
isBeingDragged && isDragging
|
||||
isDragging && isBeingDragged
|
||||
? `translate3d(0, ${dragOffsetY}px, 0)`
|
||||
: undefined,
|
||||
}}
|
||||
@@ -664,7 +664,7 @@ function KeyframeIndicators({
|
||||
}: {
|
||||
indicators: KeyframeIndicator[];
|
||||
dragState: KeyframeDragState;
|
||||
displayedStartTime: number;
|
||||
displayedStartTime: MediaTime;
|
||||
elementLeft: number;
|
||||
onKeyframeMouseDown: (params: {
|
||||
event: React.MouseEvent;
|
||||
@@ -674,13 +674,13 @@ function KeyframeIndicators({
|
||||
event: React.MouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
indicatorTime: number;
|
||||
indicatorTime: MediaTime;
|
||||
}) => void;
|
||||
getVisualOffsetPx: (params: {
|
||||
indicatorTime: number;
|
||||
indicatorTime: MediaTime;
|
||||
indicatorOffsetPx: number;
|
||||
isBeingDragged: boolean;
|
||||
displayedStartTime: number;
|
||||
displayedStartTime: MediaTime;
|
||||
elementLeft: number;
|
||||
}) => number;
|
||||
}) {
|
||||
@@ -758,7 +758,7 @@ function ExpandedKeyframeLanes({
|
||||
keyframes: ElementKeyframe[];
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
displayedStartTime: number;
|
||||
displayedStartTime: MediaTime;
|
||||
zoomLevel: number;
|
||||
elementLeft: number;
|
||||
keyframeDragState: KeyframeDragState;
|
||||
@@ -779,13 +779,13 @@ function ExpandedKeyframeLanes({
|
||||
event: React.MouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
indicatorTime: number;
|
||||
indicatorTime: MediaTime;
|
||||
}) => void;
|
||||
getVisualOffsetPx: (params: {
|
||||
indicatorTime: number;
|
||||
indicatorTime: MediaTime;
|
||||
indicatorOffsetPx: number;
|
||||
isBeingDragged: boolean;
|
||||
displayedStartTime: number;
|
||||
displayedStartTime: MediaTime;
|
||||
elementLeft: number;
|
||||
}) => number;
|
||||
}) {
|
||||
|
||||
@@ -7,7 +7,14 @@ import {
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/timeline";
|
||||
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
maxMediaTime,
|
||||
mediaTime,
|
||||
subMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
@@ -72,17 +79,24 @@ export function TimelinePlayhead({
|
||||
|
||||
event.preventDefault();
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
const ticksPerFrame = mediaTime({
|
||||
ticks: Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
),
|
||||
});
|
||||
const direction = event.key === "ArrowRight" ? 1 : -1;
|
||||
const now = editor.playback.getCurrentTime();
|
||||
const nextTime = Math.max(
|
||||
0,
|
||||
Math.min(duration, now + direction * ticksPerFrame),
|
||||
);
|
||||
const nextTime =
|
||||
direction > 0
|
||||
? addMediaTime({ a: now, b: ticksPerFrame })
|
||||
: subMediaTime({ a: now, b: ticksPerFrame });
|
||||
|
||||
editor.playback.seek({ time: nextTime });
|
||||
editor.playback.seek({
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: duration < nextTime ? duration : nextTime,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
toElementDurationTicks,
|
||||
} from "@/timeline/creation";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { roundToFrame, type FrameRate } from "opencut-wasm";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import {
|
||||
buildTextElement,
|
||||
buildGraphicElement,
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import type { ProcessedMediaAsset } from "@/media/processing";
|
||||
import { roundFrameTime, type MediaTime } from "@/wasm";
|
||||
|
||||
// --- Config ---
|
||||
|
||||
@@ -41,7 +42,7 @@ export interface DragDropConfig {
|
||||
getActiveProjectFps: () => FrameRate | null;
|
||||
getActiveProjectId: () => string | null;
|
||||
getSceneTracks: () => SceneTracks;
|
||||
getCurrentPlayheadTime: () => number;
|
||||
getCurrentPlayheadTime: () => MediaTime;
|
||||
getMediaAssets: () => MediaAsset[];
|
||||
dragSource: TimelineDragSource;
|
||||
addMediaAsset: (args: {
|
||||
@@ -116,7 +117,7 @@ function getDurationForDrag({
|
||||
}: {
|
||||
dragData: TimelineDragData;
|
||||
mediaAssets: MediaAsset[];
|
||||
}): number {
|
||||
}): MediaTime {
|
||||
if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const media = mediaAssets.find((asset) => asset.id === dragData.id);
|
||||
return toElementDurationTicks({ seconds: media?.duration });
|
||||
@@ -225,8 +226,7 @@ export class DragDropController {
|
||||
|
||||
const fps = this.config.getActiveProjectFps();
|
||||
target.xPosition = fps
|
||||
? (roundToFrame({ time: target.xPosition, rate: fps }) ??
|
||||
target.xPosition)
|
||||
? roundFrameTime({ time: target.xPosition, fps })
|
||||
: target.xPosition;
|
||||
|
||||
this.setOver({ dropTarget: target, elementType });
|
||||
|
||||
@@ -7,9 +7,17 @@ import {
|
||||
type MoveGroup,
|
||||
} from "@/timeline/group-move";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
maxMediaTime,
|
||||
type MediaTime,
|
||||
mediaTime,
|
||||
roundFrameTime,
|
||||
subMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||
import { roundToFrame, type FrameRate } from "opencut-wasm";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import { computeDropTarget } from "@/timeline/components/drop-target";
|
||||
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
@@ -52,7 +60,7 @@ export interface ElementSelectionApi {
|
||||
}
|
||||
|
||||
export interface PlaybackReader {
|
||||
getCurrentTime: () => number;
|
||||
getCurrentTime: () => MediaTime;
|
||||
}
|
||||
|
||||
export interface TimelineOps {
|
||||
@@ -86,8 +94,8 @@ interface MousedownSnapshot {
|
||||
readonly origin: Point;
|
||||
readonly elementId: string;
|
||||
readonly trackId: string;
|
||||
readonly startElementTime: number;
|
||||
readonly clickOffsetTime: number;
|
||||
readonly startElementTime: MediaTime;
|
||||
readonly clickOffsetTime: MediaTime;
|
||||
readonly selectedElements: readonly ElementRef[];
|
||||
}
|
||||
|
||||
@@ -100,7 +108,7 @@ interface DragProgress {
|
||||
// indicator, drop-line, commit path) see the same entity every frame
|
||||
// instead of a churning UUID.
|
||||
reservedNewTrackIds: readonly string[];
|
||||
currentTime: number;
|
||||
currentTime: MediaTime;
|
||||
currentMouseX: number;
|
||||
currentMouseY: number;
|
||||
groupMoveResult: GroupMoveResult | null;
|
||||
@@ -120,10 +128,10 @@ function pixelToClickOffsetTime(
|
||||
clientX: number,
|
||||
elementRect: DOMRect,
|
||||
zoomLevel: number,
|
||||
): number {
|
||||
): MediaTime {
|
||||
const clickOffsetX = clientX - elementRect.left;
|
||||
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
||||
return Math.round(seconds * TICKS_PER_SECOND);
|
||||
return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) });
|
||||
}
|
||||
|
||||
function verticalDirection(
|
||||
@@ -156,17 +164,20 @@ function frameSnappedMouseTime({
|
||||
clientX: number;
|
||||
scrollContainer: HTMLDivElement;
|
||||
zoomLevel: number;
|
||||
clickOffsetTime: number;
|
||||
clickOffsetTime: MediaTime;
|
||||
fps: FrameRate;
|
||||
}): number {
|
||||
}): MediaTime {
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft: scrollContainer.scrollLeft,
|
||||
});
|
||||
const adjusted = Math.max(0, mouseTime - clickOffsetTime);
|
||||
return roundToFrame({ time: adjusted, rate: fps }) ?? adjusted;
|
||||
const adjusted = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({ a: mouseTime, b: clickOffsetTime }),
|
||||
});
|
||||
return roundFrameTime({ time: adjusted, fps });
|
||||
}
|
||||
|
||||
function resolveDropTarget({
|
||||
@@ -187,7 +198,7 @@ function resolveDropTarget({
|
||||
tracks: SceneTracks;
|
||||
viewport: ViewportAdapter;
|
||||
zoomLevel: number;
|
||||
snappedTime: number;
|
||||
snappedTime: MediaTime;
|
||||
verticalDragDirection: "up" | "down" | null;
|
||||
}): DropTarget | null {
|
||||
const containerRect = viewport
|
||||
@@ -231,7 +242,7 @@ function resolveGroupMoveForDrop({
|
||||
}: {
|
||||
group: MoveGroup;
|
||||
tracks: SceneTracks;
|
||||
anchorStartTime: number;
|
||||
anchorStartTime: MediaTime;
|
||||
dropTarget: DropTarget;
|
||||
reservedNewTrackIds: readonly string[];
|
||||
}): GroupMoveResult | null {
|
||||
@@ -286,7 +297,7 @@ export class ElementInteractionController {
|
||||
get view(): ElementDragView {
|
||||
if (this.session.kind !== "dragging") return IDLE_VIEW;
|
||||
const { mousedown, drag } = this.session;
|
||||
const memberTimeOffsets = new Map<string, number>();
|
||||
const memberTimeOffsets = new Map<string, MediaTime>();
|
||||
for (const member of drag.moveGroup.members) {
|
||||
memberTimeOffsets.set(member.elementId, member.timeOffset);
|
||||
}
|
||||
@@ -427,9 +438,9 @@ export class ElementInteractionController {
|
||||
}
|
||||
|
||||
private snapResult(
|
||||
frameSnappedTime: number,
|
||||
frameSnappedTime: MediaTime,
|
||||
group: MoveGroup,
|
||||
): { snappedTime: number; snapPoint: SnapPoint | null } {
|
||||
): { snappedTime: MediaTime; snapPoint: SnapPoint | null } {
|
||||
const { snap, input, scene, viewport, playback } = this.deps;
|
||||
|
||||
if (!snap.isEnabled() || input.isShiftHeld()) {
|
||||
@@ -461,7 +472,7 @@ export class ElementInteractionController {
|
||||
clientY: number;
|
||||
mousedown: MousedownSnapshot;
|
||||
drag: DragProgress;
|
||||
snappedTime: number;
|
||||
snappedTime: MediaTime;
|
||||
}): void {
|
||||
const { scene, viewport } = this.deps;
|
||||
const tracks = scene.getTracks();
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
clampMediaTime,
|
||||
type MediaTime,
|
||||
mediaTime,
|
||||
roundFrameTicks,
|
||||
snapSeekMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||
import { timelineTimeToSnappedPixels } from "@/timeline";
|
||||
import { getKeyframeById } from "@/animation";
|
||||
@@ -48,7 +57,7 @@ export interface KeyframeDragConfig {
|
||||
zoomLevel: number;
|
||||
getFps: () => FrameRate | null;
|
||||
element: TimelineElement;
|
||||
displayedStartTime: number;
|
||||
displayedStartTime: MediaTime;
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean;
|
||||
setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void;
|
||||
@@ -62,8 +71,8 @@ export interface KeyframeDragConfig {
|
||||
isAdditive: boolean;
|
||||
}) => void;
|
||||
executeCommand: (command: Command) => void;
|
||||
seek: (args: { time: number }) => void;
|
||||
getTotalDuration: () => number;
|
||||
seek: (args: { time: MediaTime }) => void;
|
||||
getTotalDuration: () => MediaTime;
|
||||
}
|
||||
|
||||
export interface KeyframeDragConfigRef {
|
||||
@@ -161,7 +170,7 @@ export class KeyframeDragController {
|
||||
event: ReactMouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
indicatorTime: number;
|
||||
indicatorTime: MediaTime;
|
||||
}): void {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -174,14 +183,18 @@ export class KeyframeDragController {
|
||||
|
||||
const { displayedStartTime, getFps, getTotalDuration, seek } = this.config;
|
||||
const fps = getFps();
|
||||
const absoluteIndicatorTime = addMediaTime({
|
||||
a: displayedStartTime,
|
||||
b: indicatorTime,
|
||||
});
|
||||
const seekTime =
|
||||
fps != null
|
||||
? (snappedSeekTime({
|
||||
time: displayedStartTime + indicatorTime,
|
||||
? snapSeekMediaTime({
|
||||
time: absoluteIndicatorTime,
|
||||
duration: getTotalDuration(),
|
||||
rate: fps,
|
||||
}) ?? displayedStartTime + indicatorTime)
|
||||
: displayedStartTime + indicatorTime;
|
||||
fps,
|
||||
})
|
||||
: absoluteIndicatorTime;
|
||||
seek({ time: seekTime });
|
||||
|
||||
if (event.shiftKey) {
|
||||
@@ -206,24 +219,23 @@ export class KeyframeDragController {
|
||||
displayedStartTime,
|
||||
elementLeft,
|
||||
}: {
|
||||
indicatorTime: number;
|
||||
indicatorTime: MediaTime;
|
||||
indicatorOffsetPx: number;
|
||||
isBeingDragged: boolean;
|
||||
displayedStartTime: number;
|
||||
displayedStartTime: MediaTime;
|
||||
elementLeft: number;
|
||||
}): number {
|
||||
if (!isBeingDragged || this.session.kind !== "active")
|
||||
return indicatorOffsetPx;
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.config.element.duration,
|
||||
indicatorTime + this.session.deltaTicks,
|
||||
),
|
||||
);
|
||||
const deltaTime = mediaTime({ ticks: this.session.deltaTicks });
|
||||
const clampedTime = clampMediaTime({
|
||||
time: addMediaTime({ a: indicatorTime, b: deltaTime }),
|
||||
min: ZERO_MEDIA_TIME,
|
||||
max: this.config.element.duration,
|
||||
});
|
||||
return (
|
||||
timelineTimeToSnappedPixels({
|
||||
time: displayedStartTime + clampedTime,
|
||||
time: addMediaTime({ a: displayedStartTime, b: clampedTime }),
|
||||
zoomLevel: this.config.zoomLevel,
|
||||
}) - elementLeft
|
||||
);
|
||||
@@ -270,10 +282,14 @@ export class KeyframeDragController {
|
||||
elementId: ref.elementId,
|
||||
propertyPath: ref.propertyPath,
|
||||
keyframeId: ref.keyframeId,
|
||||
nextTime: Math.max(
|
||||
0,
|
||||
Math.min(element.duration, keyframe.time + deltaTicks),
|
||||
),
|
||||
nextTime: clampMediaTime({
|
||||
time: addMediaTime({
|
||||
a: keyframe.time,
|
||||
b: mediaTime({ ticks: deltaTicks }),
|
||||
}),
|
||||
min: ZERO_MEDIA_TIME,
|
||||
max: element.duration,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
});
|
||||
@@ -313,8 +329,7 @@ export class KeyframeDragController {
|
||||
((clientX - this.session.startMouseX) / pixelsPerSecond) *
|
||||
TICKS_PER_SECOND,
|
||||
);
|
||||
this.session.deltaTicks =
|
||||
roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks;
|
||||
this.session.deltaTicks = roundFrameTicks({ ticks: rawDeltaTicks, fps });
|
||||
this.notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { snappedSeekTime, type FrameRate } from "opencut-wasm";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import {
|
||||
mediaTime,
|
||||
snapSeekMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
type MediaTime,
|
||||
} from "@/wasm";
|
||||
import {
|
||||
buildTimelineSnapPoints,
|
||||
getTimelineSnapThresholdInTicks,
|
||||
@@ -8,7 +13,7 @@ import {
|
||||
} from "@/timeline/snapping";
|
||||
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||
import {
|
||||
getCenteredLineLeft,
|
||||
timelineTimeToPixels,
|
||||
@@ -26,7 +31,7 @@ interface ScrubSession {
|
||||
/** True once the mouse has moved during a ruler drag. */
|
||||
hasMoved: boolean;
|
||||
/** Most recent frame-snapped time set by scrub(). */
|
||||
currentTime: number | null;
|
||||
currentTime: MediaTime | null;
|
||||
}
|
||||
|
||||
type Session = { kind: "idle" } | ScrubSession;
|
||||
@@ -35,7 +40,7 @@ type Session = { kind: "idle" } | ScrubSession;
|
||||
|
||||
export interface PlayheadConfig {
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
duration: MediaTime;
|
||||
getActiveProjectFps: () => FrameRate | null;
|
||||
isShiftHeld: () => boolean;
|
||||
getIsPlaying: () => boolean;
|
||||
@@ -45,12 +50,12 @@ export interface PlayheadConfig {
|
||||
getPlayheadEl: () => HTMLDivElement | null;
|
||||
getSceneTracks: () => SceneTracks;
|
||||
getSceneBookmarks: () => Bookmark[];
|
||||
seek: (time: number) => void;
|
||||
seek: (time: MediaTime) => void;
|
||||
setScrubbing: (isScrubbing: boolean) => void;
|
||||
setTimelineViewState: (viewState: {
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
playheadTime: number;
|
||||
playheadTime: MediaTime;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -69,8 +74,8 @@ function pixelToTime({
|
||||
clientX: number;
|
||||
rulerEl: HTMLDivElement;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
}): number {
|
||||
duration: MediaTime;
|
||||
}): MediaTime {
|
||||
const rulerRect = rulerEl.getBoundingClientRect();
|
||||
const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
|
||||
const clampedX = Math.max(
|
||||
@@ -84,7 +89,7 @@ function pixelToTime({
|
||||
clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
return Math.round(seconds * TICKS_PER_SECOND);
|
||||
return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) });
|
||||
}
|
||||
|
||||
// --- Controller ---
|
||||
@@ -159,7 +164,7 @@ export class PlayheadController {
|
||||
* Called on scroll and playback events to avoid React re-renders
|
||||
* during animation frame updates.
|
||||
*/
|
||||
updatePlayheadLeft(time: number): void {
|
||||
updatePlayheadLeft(time: MediaTime): void {
|
||||
const playheadEl = this.config.getPlayheadEl();
|
||||
if (!playheadEl) return;
|
||||
|
||||
@@ -175,7 +180,7 @@ export class PlayheadController {
|
||||
* Updates the playhead position and auto-scrolls to keep the playhead
|
||||
* visible during playback.
|
||||
*/
|
||||
handlePlaybackUpdate(time: number): void {
|
||||
handlePlaybackUpdate(time: MediaTime): void {
|
||||
this.updatePlayheadLeft(time);
|
||||
|
||||
// Auto-scroll only during playback, not while scrubbing.
|
||||
@@ -244,8 +249,7 @@ export class PlayheadController {
|
||||
zoomLevel,
|
||||
duration,
|
||||
});
|
||||
const frameTime =
|
||||
snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime;
|
||||
const frameTime = snapSeekMediaTime({ time: rawTime, duration, fps });
|
||||
|
||||
const time = (() => {
|
||||
if (!isElementSnappingEnabled || this.config.isShiftHeld())
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
maxMediaTime,
|
||||
type MediaTime,
|
||||
mediaTime,
|
||||
minMediaTime,
|
||||
subMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
} from "@/wasm";
|
||||
import {
|
||||
computeGroupResize,
|
||||
type GroupResizeMember,
|
||||
@@ -16,7 +24,7 @@ import {
|
||||
} from "@/timeline/snapping";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||
import {
|
||||
isRetimableElement,
|
||||
type SceneTracks,
|
||||
@@ -46,7 +54,7 @@ export interface ResizeConfig {
|
||||
snappingEnabled: boolean;
|
||||
isShiftHeld: () => boolean;
|
||||
getSceneTracks: () => SceneTracks;
|
||||
getCurrentPlayheadTime: () => number;
|
||||
getCurrentPlayheadTime: () => MediaTime;
|
||||
getActiveProjectFps: () => FrameRate | null;
|
||||
selectedElements: ElementRef[];
|
||||
discardPreview: () => void;
|
||||
@@ -87,14 +95,32 @@ export function buildResizeMembers({
|
||||
(el) => !selectedElementIds.has(el.id),
|
||||
);
|
||||
const leftNeighborBound = otherElements
|
||||
.filter((el) => el.startTime + el.duration <= element.startTime)
|
||||
.reduce(
|
||||
(bound, el) => Math.max(bound, el.startTime + el.duration),
|
||||
-Infinity,
|
||||
);
|
||||
.filter(
|
||||
(el) =>
|
||||
addMediaTime({ a: el.startTime, b: el.duration }) <=
|
||||
element.startTime,
|
||||
)
|
||||
.reduce<MediaTime | null>((bound, el) => {
|
||||
const elementEnd = addMediaTime({
|
||||
a: el.startTime,
|
||||
b: el.duration,
|
||||
});
|
||||
return bound === null
|
||||
? elementEnd
|
||||
: maxMediaTime({ a: bound, b: elementEnd });
|
||||
}, null);
|
||||
const rightNeighborBound = otherElements
|
||||
.filter((el) => el.startTime >= element.startTime + element.duration)
|
||||
.reduce((bound, el) => Math.min(bound, el.startTime), Infinity);
|
||||
.filter(
|
||||
(el) =>
|
||||
el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }),
|
||||
)
|
||||
.reduce<MediaTime | null>(
|
||||
(bound, el) =>
|
||||
bound === null
|
||||
? el.startTime
|
||||
: minMediaTime({ a: bound, b: el.startTime }),
|
||||
null,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -237,12 +263,15 @@ export class ResizeController {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private snappedDelta(session: ResizeSession, rawDeltaTicks: number): number {
|
||||
private snappedDelta(
|
||||
session: ResizeSession,
|
||||
rawDeltaTime: MediaTime,
|
||||
): MediaTime {
|
||||
const { snappingEnabled, isShiftHeld, zoomLevel } = this.config;
|
||||
|
||||
if (!snappingEnabled || isShiftHeld()) {
|
||||
this.config.onSnapPointChange?.(null);
|
||||
return rawDeltaTicks;
|
||||
return rawDeltaTime;
|
||||
}
|
||||
|
||||
const tracks = this.config.getSceneTracks();
|
||||
@@ -264,15 +293,15 @@ export class ResizeController {
|
||||
|
||||
let closestSnapPoint: SnapPoint | null = null;
|
||||
let closestSnapDistance = Infinity;
|
||||
let deltaTicks = rawDeltaTicks;
|
||||
let deltaTime = rawDeltaTime;
|
||||
|
||||
for (const member of session.members) {
|
||||
const baseEdgeTime =
|
||||
session.side === "left"
|
||||
? member.startTime
|
||||
: member.startTime + member.duration;
|
||||
: addMediaTime({ a: member.startTime, b: member.duration });
|
||||
const snapResult = resolveTimelineSnap({
|
||||
targetTime: baseEdgeTime + rawDeltaTicks,
|
||||
targetTime: addMediaTime({ a: baseEdgeTime, b: rawDeltaTime }),
|
||||
snapPoints,
|
||||
maxSnapDistance,
|
||||
});
|
||||
@@ -282,28 +311,30 @@ export class ResizeController {
|
||||
) {
|
||||
closestSnapDistance = snapResult.snapDistance;
|
||||
closestSnapPoint = snapResult.snapPoint;
|
||||
deltaTicks = snapResult.snappedTime - baseEdgeTime;
|
||||
deltaTime = subMediaTime({ a: snapResult.snappedTime, b: baseEdgeTime });
|
||||
}
|
||||
}
|
||||
|
||||
this.config.onSnapPointChange?.(closestSnapPoint);
|
||||
return deltaTicks;
|
||||
return deltaTime;
|
||||
}
|
||||
|
||||
private handleMouseMove({ clientX }: MouseEvent): void {
|
||||
if (this.session.kind !== "active") return;
|
||||
const session = this.session;
|
||||
|
||||
const rawDeltaTicks = Math.round(
|
||||
((clientX - session.startX) /
|
||||
(BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
|
||||
TICKS_PER_SECOND,
|
||||
);
|
||||
const deltaTicks = this.snappedDelta(session, rawDeltaTicks);
|
||||
const rawDeltaTime = mediaTime({
|
||||
ticks: Math.round(
|
||||
((clientX - session.startX) /
|
||||
(BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
|
||||
TICKS_PER_SECOND,
|
||||
),
|
||||
});
|
||||
const deltaTime = this.snappedDelta(session, rawDeltaTime);
|
||||
const result = computeGroupResize({
|
||||
members: session.members,
|
||||
side: session.side,
|
||||
deltaTicks,
|
||||
deltaTime,
|
||||
fps: session.fps,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { snappedSeekTime, type FrameRate } from "opencut-wasm";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } from "@/wasm";
|
||||
|
||||
type SeekSource = "ruler" | "tracks";
|
||||
|
||||
@@ -17,7 +17,7 @@ type Session = { kind: "idle" } | PendingSeekSession;
|
||||
|
||||
export interface SeekConfig {
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
duration: MediaTime;
|
||||
isSelecting: boolean;
|
||||
getPlayheadEl: () => HTMLDivElement | null;
|
||||
getTrackLabelsEl: () => HTMLDivElement | null;
|
||||
@@ -25,11 +25,11 @@ export interface SeekConfig {
|
||||
getTracksScrollEl: () => HTMLDivElement | null;
|
||||
getActiveProjectFps: () => FrameRate | null;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
seek: (time: MediaTime) => void;
|
||||
setTimelineViewState: (viewState: {
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
playheadTime: number;
|
||||
playheadTime: MediaTime;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ function pixelToTime({
|
||||
clientX: number;
|
||||
scrollContainer: HTMLDivElement;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
}): number {
|
||||
duration: MediaTime;
|
||||
}): MediaTime {
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
@@ -60,7 +60,7 @@ function pixelToTime({
|
||||
),
|
||||
);
|
||||
|
||||
return Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
||||
return mediaTime({ ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND) });
|
||||
}
|
||||
|
||||
function isClickGesture({
|
||||
@@ -193,11 +193,11 @@ export class SeekController {
|
||||
const fps = this.config.getActiveProjectFps();
|
||||
const time =
|
||||
fps != null
|
||||
? (snappedSeekTime({
|
||||
? snapSeekMediaTime({
|
||||
time: rawTime,
|
||||
duration: this.config.duration,
|
||||
rate: fps,
|
||||
}) ?? rawTime)
|
||||
fps,
|
||||
})
|
||||
: rawTime;
|
||||
|
||||
this.config.seek(time);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/i
|
||||
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
|
||||
import { TIMELINE_ZOOM_MAX } from "@/timeline/scale";
|
||||
import { zoomToSlider } from "@/timeline/zoom-utils";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
type ZoomUpdater = number | ((prev: number) => number);
|
||||
|
||||
@@ -11,12 +12,12 @@ export interface ZoomConfig {
|
||||
getContainerEl: () => HTMLDivElement | null;
|
||||
getTracksScrollEl: () => HTMLDivElement | null;
|
||||
getRulerScrollEl: () => HTMLDivElement | null;
|
||||
getCurrentPlayheadTime: () => number;
|
||||
seek: (time: number) => void;
|
||||
getCurrentPlayheadTime: () => MediaTime;
|
||||
seek: (time: MediaTime) => void;
|
||||
setTimelineViewState: (viewState: {
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
playheadTime: number;
|
||||
playheadTime: MediaTime;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -249,7 +250,7 @@ export class ZoomController {
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void {
|
||||
restoreInitialPlayheadIfNeeded(initialPlayheadTime?: MediaTime): void {
|
||||
if (initialPlayheadTime === undefined) return;
|
||||
if (this.hasRestoredPlayhead) return;
|
||||
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { TICKS_PER_SECOND } from "@/wasm/ticks";
|
||||
import { mediaTime, mediaTimeFromSeconds, TICKS_PER_SECOND } from "@/wasm";
|
||||
|
||||
export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND;
|
||||
export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({
|
||||
ticks: 5 * TICKS_PER_SECOND,
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves the duration in ticks that a newly created timeline element
|
||||
* should have, given an optional source duration in seconds.
|
||||
*
|
||||
* Falls back to {@link DEFAULT_NEW_ELEMENT_DURATION} when the source has
|
||||
* no known duration (e.g. stickers, graphics, text, or media whose
|
||||
* probe failed).
|
||||
*/
|
||||
export function toElementDurationTicks({
|
||||
seconds,
|
||||
}: {
|
||||
seconds: number | null | undefined;
|
||||
}): number {
|
||||
if (seconds == null) return DEFAULT_NEW_ELEMENT_DURATION;
|
||||
return Math.round(seconds * TICKS_PER_SECOND);
|
||||
}) {
|
||||
if (seconds == null) {
|
||||
return DEFAULT_NEW_ELEMENT_DURATION;
|
||||
}
|
||||
|
||||
return mediaTimeFromSeconds({ seconds });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||
import type { TTimelineViewState } from "@/project/types";
|
||||
import type { BlendMode, Transform } from "@/rendering";
|
||||
import { ZERO_MEDIA_TIME } from "@/wasm";
|
||||
import type { TextElement } from "./types";
|
||||
|
||||
const defaultTransform: Transform = {
|
||||
@@ -42,9 +43,9 @@ const defaultTextElement: Omit<TextElement, "id"> = {
|
||||
letterSpacing: defaultTextLetterSpacing,
|
||||
lineHeight: defaultTextLineHeight,
|
||||
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
startTime: ZERO_MEDIA_TIME,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
transform: {
|
||||
...defaultTransform,
|
||||
position: { ...defaultTransform.position },
|
||||
@@ -55,7 +56,7 @@ const defaultTextElement: Omit<TextElement, "id"> = {
|
||||
const defaultTimelineViewState: TTimelineViewState = {
|
||||
zoomLevel: 1,
|
||||
scrollLeft: 0,
|
||||
playheadTime: 0,
|
||||
playheadTime: ZERO_MEDIA_TIME,
|
||||
};
|
||||
|
||||
export const DEFAULTS = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
||||
|
||||
export function getMouseTimeFromClientX({
|
||||
clientX,
|
||||
@@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({
|
||||
containerRect: DOMRect;
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
}): number {
|
||||
}): MediaTime {
|
||||
const mouseX = clientX - containerRect.left + scrollLeft;
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
);
|
||||
return Math.round(seconds * TICKS_PER_SECOND);
|
||||
return mediaTime({
|
||||
ticks: Math.round(seconds * TICKS_PER_SECOND),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import { addMediaTime } from "@/wasm";
|
||||
|
||||
export function getElementEdgeSnapPoints({
|
||||
tracks,
|
||||
@@ -25,7 +26,7 @@ export function getElementEdgeSnapPoints({
|
||||
trackId: track.id,
|
||||
},
|
||||
{
|
||||
time: element.startTime + element.duration,
|
||||
time: addMediaTime({ a: element.startTime, b: element.duration }),
|
||||
type: "element-end",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { buildDefaultEffectInstance } from "@/effects";
|
||||
import { buildDefaultGraphicInstance } from "@/graphics";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { capitalizeFirstLetter } from "@/utils/string";
|
||||
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||
|
||||
export function canElementHaveAudio(
|
||||
element: TimelineElement,
|
||||
@@ -107,7 +108,7 @@ export function buildTextElement({
|
||||
startTime,
|
||||
}: {
|
||||
raw: Partial<Omit<TextElement, "type" | "id">>;
|
||||
startTime: number;
|
||||
startTime: MediaTime;
|
||||
}): CreateTimelineElement {
|
||||
const t = raw as Partial<TextElement>;
|
||||
|
||||
@@ -117,8 +118,8 @@ export function buildTextElement({
|
||||
content: t.content ?? DEFAULTS.text.element.content,
|
||||
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
|
||||
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
|
||||
color: t.color ?? DEFAULTS.text.element.color,
|
||||
@@ -141,8 +142,8 @@ export function buildEffectElement({
|
||||
duration,
|
||||
}: {
|
||||
effectType: string;
|
||||
startTime: number;
|
||||
duration?: number;
|
||||
startTime: MediaTime;
|
||||
duration?: MediaTime;
|
||||
}): CreateEffectElement {
|
||||
const instance = buildDefaultEffectInstance({ effectType });
|
||||
return {
|
||||
@@ -152,8 +153,8 @@ export function buildEffectElement({
|
||||
params: instance.params,
|
||||
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,7 +167,7 @@ export function buildStickerElement({
|
||||
}: {
|
||||
stickerId: string;
|
||||
name?: string;
|
||||
startTime: number;
|
||||
startTime: MediaTime;
|
||||
intrinsicWidth?: number;
|
||||
intrinsicHeight?: number;
|
||||
}): CreateStickerElement {
|
||||
@@ -180,8 +181,8 @@ export function buildStickerElement({
|
||||
intrinsicHeight,
|
||||
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
transform: {
|
||||
...DEFAULTS.element.transform,
|
||||
position: { ...DEFAULTS.element.transform.position },
|
||||
@@ -199,7 +200,7 @@ export function buildGraphicElement({
|
||||
}: {
|
||||
definitionId: string;
|
||||
name?: string;
|
||||
startTime: number;
|
||||
startTime: MediaTime;
|
||||
params?: Partial<ParamValues>;
|
||||
}): CreateGraphicElement {
|
||||
const instance = buildDefaultGraphicInstance({ definitionId });
|
||||
@@ -210,8 +211,8 @@ export function buildGraphicElement({
|
||||
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
|
||||
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
transform: {
|
||||
...DEFAULTS.element.transform,
|
||||
position: { ...DEFAULTS.element.transform.position },
|
||||
@@ -229,8 +230,8 @@ function buildVideoElement({
|
||||
}: {
|
||||
mediaId: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
duration: MediaTime;
|
||||
startTime: MediaTime;
|
||||
}): CreateVideoElement {
|
||||
return {
|
||||
type: "video",
|
||||
@@ -238,8 +239,8 @@ function buildVideoElement({
|
||||
name,
|
||||
duration,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
sourceDuration: duration,
|
||||
muted: false,
|
||||
isSourceAudioEnabled: true,
|
||||
@@ -262,8 +263,8 @@ function buildImageElement({
|
||||
}: {
|
||||
mediaId: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
duration: MediaTime;
|
||||
startTime: MediaTime;
|
||||
}): CreateImageElement {
|
||||
return {
|
||||
type: "image",
|
||||
@@ -271,8 +272,8 @@ function buildImageElement({
|
||||
name,
|
||||
duration,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
hidden: false,
|
||||
transform: {
|
||||
...DEFAULTS.element.transform,
|
||||
@@ -292,8 +293,8 @@ function buildUploadAudioElement({
|
||||
}: {
|
||||
mediaId: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
duration: MediaTime;
|
||||
startTime: MediaTime;
|
||||
buffer?: AudioBuffer;
|
||||
}): CreateUploadAudioElement {
|
||||
const element: CreateUploadAudioElement = {
|
||||
@@ -303,8 +304,8 @@ function buildUploadAudioElement({
|
||||
name,
|
||||
duration,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
sourceDuration: duration,
|
||||
volume: DEFAULTS.element.volume,
|
||||
muted: false,
|
||||
@@ -326,8 +327,8 @@ export function buildElementFromMedia({
|
||||
mediaId: string;
|
||||
mediaType: MediaType;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
duration: MediaTime;
|
||||
startTime: MediaTime;
|
||||
buffer?: AudioBuffer;
|
||||
}): CreateTimelineElement {
|
||||
switch (mediaType) {
|
||||
@@ -355,8 +356,8 @@ export function buildLibraryAudioElement({
|
||||
}: {
|
||||
sourceUrl: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
duration: MediaTime;
|
||||
startTime: MediaTime;
|
||||
buffer?: AudioBuffer;
|
||||
}): CreateLibraryAudioElement {
|
||||
const element: CreateLibraryAudioElement = {
|
||||
@@ -366,8 +367,8 @@ export function buildLibraryAudioElement({
|
||||
name,
|
||||
duration,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
sourceDuration: duration,
|
||||
volume: DEFAULTS.element.volume,
|
||||
muted: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline";
|
||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||
import type { GroupMember, MoveGroup } from "./types";
|
||||
import { getTrackPlacementById } from "./track-placement";
|
||||
import { subMediaTime } from "@/wasm";
|
||||
|
||||
export function buildMoveGroup({
|
||||
anchorRef,
|
||||
@@ -59,7 +60,10 @@ export function buildMoveGroup({
|
||||
elementId: element.id,
|
||||
elementType: element.type,
|
||||
duration: element.duration,
|
||||
timeOffset: element.startTime - anchorElement.startTime,
|
||||
timeOffset: subMediaTime({
|
||||
a: element.startTime,
|
||||
b: anchorElement.startTime,
|
||||
}),
|
||||
trackSection: placement.section,
|
||||
sectionIndex: placement.sectionIndex,
|
||||
displayIndex: placement.displayIndex,
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
getTrackPlacementByDisplayIndex,
|
||||
getTrackPlacementById,
|
||||
} from "./track-placement";
|
||||
import {
|
||||
addMediaTime,
|
||||
maxMediaTime,
|
||||
type MediaTime,
|
||||
subMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
type GroupMoveTarget =
|
||||
| {
|
||||
@@ -32,7 +39,7 @@ export function resolveGroupMove({
|
||||
}: {
|
||||
group: MoveGroup;
|
||||
tracks: SceneTracks;
|
||||
anchorStartTime: number;
|
||||
anchorStartTime: MediaTime;
|
||||
target: GroupMoveTarget;
|
||||
}): GroupMoveResult | null {
|
||||
if (target.kind === "newTracks") {
|
||||
@@ -61,7 +68,7 @@ function resolveExistingTrackMove({
|
||||
}: {
|
||||
group: MoveGroup;
|
||||
tracks: SceneTracks;
|
||||
anchorStartTime: number;
|
||||
anchorStartTime: MediaTime;
|
||||
anchorTargetTrackId: string;
|
||||
}): GroupMoveResult | null {
|
||||
const anchorTargetPlacement = getTrackPlacementById({
|
||||
@@ -93,7 +100,10 @@ function resolveExistingTrackMove({
|
||||
targetTrackId:
|
||||
targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
|
||||
elementId: member.elementId,
|
||||
newStartTime: clampedAnchorStartTime + member.timeOffset,
|
||||
newStartTime: addMediaTime({
|
||||
a: clampedAnchorStartTime,
|
||||
b: member.timeOffset,
|
||||
}),
|
||||
}));
|
||||
|
||||
if (!canApplyMovesToExistingTracks({ tracks, moves })) {
|
||||
@@ -119,7 +129,7 @@ function resolveNewTrackMove({
|
||||
}: {
|
||||
group: MoveGroup;
|
||||
tracks: SceneTracks;
|
||||
anchorStartTime: number;
|
||||
anchorStartTime: MediaTime;
|
||||
anchorInsertIndex: number;
|
||||
newTrackIds: string[];
|
||||
}): GroupMoveResult | null {
|
||||
@@ -173,7 +183,10 @@ function resolveNewTrackMove({
|
||||
sourceTrackId: member.trackId,
|
||||
targetTrackId: newTrackIds[memberIndex],
|
||||
elementId: member.elementId,
|
||||
newStartTime: clampedAnchorStartTime + member.timeOffset,
|
||||
newStartTime: addMediaTime({
|
||||
a: clampedAnchorStartTime,
|
||||
b: member.timeOffset,
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -335,17 +348,26 @@ function clampAnchorStartTime({
|
||||
}: {
|
||||
group: MoveGroup;
|
||||
tracks: SceneTracks;
|
||||
anchorStartTime: number;
|
||||
anchorStartTime: MediaTime;
|
||||
targetTrackIdsByElementId: Map<string, string>;
|
||||
}): number {
|
||||
const minimumAnchorStartTime = Math.max(
|
||||
0,
|
||||
...group.members.map((member) => -member.timeOffset),
|
||||
);
|
||||
let clampedAnchorStartTime = Math.max(
|
||||
minimumAnchorStartTime,
|
||||
anchorStartTime,
|
||||
}): MediaTime {
|
||||
const minimumAnchorStartTime = group.members.reduce(
|
||||
(minimumStartTime, member) =>
|
||||
member.timeOffset < ZERO_MEDIA_TIME
|
||||
? maxMediaTime({
|
||||
a: minimumStartTime,
|
||||
b: subMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: member.timeOffset,
|
||||
}),
|
||||
})
|
||||
: minimumStartTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
);
|
||||
let clampedAnchorStartTime =
|
||||
anchorStartTime < minimumAnchorStartTime
|
||||
? minimumAnchorStartTime
|
||||
: anchorStartTime;
|
||||
|
||||
const memberOnMainTrack = group.members.find(
|
||||
(member) =>
|
||||
@@ -358,11 +380,13 @@ function clampAnchorStartTime({
|
||||
const movingElementIds = new Set(
|
||||
group.members.map((member) => member.elementId),
|
||||
);
|
||||
const requestedMainStartTime =
|
||||
clampedAnchorStartTime + memberOnMainTrack.timeOffset;
|
||||
const requestedMainStartTime = addMediaTime({
|
||||
a: clampedAnchorStartTime,
|
||||
b: memberOnMainTrack.timeOffset,
|
||||
});
|
||||
const earliestStationaryMainStartTime = tracks.main.elements
|
||||
.filter((element) => !movingElementIds.has(element.id))
|
||||
.reduce<number | null>((earliestStartTime, element) => {
|
||||
.reduce<MediaTime | null>((earliestStartTime, element) => {
|
||||
if (earliestStartTime == null || element.startTime < earliestStartTime) {
|
||||
return element.startTime;
|
||||
}
|
||||
@@ -373,10 +397,13 @@ function clampAnchorStartTime({
|
||||
earliestStationaryMainStartTime == null ||
|
||||
requestedMainStartTime <= earliestStationaryMainStartTime
|
||||
) {
|
||||
clampedAnchorStartTime = Math.max(
|
||||
minimumAnchorStartTime,
|
||||
-memberOnMainTrack.timeOffset,
|
||||
);
|
||||
clampedAnchorStartTime = maxMediaTime({
|
||||
a: minimumAnchorStartTime,
|
||||
b: subMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: memberOnMainTrack.timeOffset,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return clampedAnchorStartTime;
|
||||
@@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({
|
||||
const sourceElement = sourceElements.get(move.elementId);
|
||||
return {
|
||||
startTime: move.newStartTime,
|
||||
duration: sourceElement?.duration ?? 0,
|
||||
duration: sourceElement?.duration ?? ZERO_MEDIA_TIME,
|
||||
};
|
||||
});
|
||||
if (hasOverlappingTimeSpans({ timeSpans })) {
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
} from "@/timeline/snapping";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||
import type { MoveGroup } from "./types";
|
||||
import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm";
|
||||
|
||||
export function snapGroupEdges({
|
||||
group,
|
||||
@@ -18,12 +19,12 @@ export function snapGroupEdges({
|
||||
zoomLevel,
|
||||
}: {
|
||||
group: MoveGroup;
|
||||
anchorStartTime: number;
|
||||
anchorStartTime: MediaTime;
|
||||
tracks: SceneTracks;
|
||||
playheadTime: number;
|
||||
playheadTime: MediaTime;
|
||||
zoomLevel: number;
|
||||
}): {
|
||||
snappedAnchorStartTime: number;
|
||||
snappedAnchorStartTime: MediaTime;
|
||||
snapPoint: SnapPoint | null;
|
||||
} {
|
||||
const excludeElementIds = new Set(
|
||||
@@ -47,7 +48,10 @@ export function snapGroupEdges({
|
||||
let snapPoint: SnapPoint | null = null;
|
||||
|
||||
for (const member of group.members) {
|
||||
const memberStartTime = anchorStartTime + member.timeOffset;
|
||||
const memberStartTime = addMediaTime({
|
||||
a: anchorStartTime,
|
||||
b: member.timeOffset,
|
||||
});
|
||||
const memberStartSnap = resolveTimelineSnap({
|
||||
targetTime: memberStartTime,
|
||||
snapPoints,
|
||||
@@ -58,12 +62,18 @@ export function snapGroupEdges({
|
||||
memberStartSnap.snapDistance < closestSnapDistance
|
||||
) {
|
||||
closestSnapDistance = memberStartSnap.snapDistance;
|
||||
snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
|
||||
snappedAnchorStartTime = subMediaTime({
|
||||
a: memberStartSnap.snappedTime,
|
||||
b: member.timeOffset,
|
||||
});
|
||||
snapPoint = memberStartSnap.snapPoint;
|
||||
}
|
||||
|
||||
const memberEndSnap = resolveTimelineSnap({
|
||||
targetTime: memberStartTime + member.duration,
|
||||
targetTime: addMediaTime({
|
||||
a: memberStartTime,
|
||||
b: member.duration,
|
||||
}),
|
||||
snapPoints,
|
||||
maxSnapDistance,
|
||||
});
|
||||
@@ -72,8 +82,13 @@ export function snapGroupEdges({
|
||||
memberEndSnap.snapDistance < closestSnapDistance
|
||||
) {
|
||||
closestSnapDistance = memberEndSnap.snapDistance;
|
||||
snappedAnchorStartTime =
|
||||
memberEndSnap.snappedTime - member.duration - member.timeOffset;
|
||||
snappedAnchorStartTime = subMediaTime({
|
||||
a: subMediaTime({
|
||||
a: memberEndSnap.snappedTime,
|
||||
b: member.duration,
|
||||
}),
|
||||
b: member.timeOffset,
|
||||
});
|
||||
snapPoint = memberEndSnap.snapPoint;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ElementRef, ElementType, TrackType } from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export type GroupTrackSection = "overlay" | "main" | "audio";
|
||||
|
||||
export interface GroupMember extends ElementRef {
|
||||
elementType: ElementType;
|
||||
duration: number;
|
||||
timeOffset: number;
|
||||
duration: MediaTime;
|
||||
timeOffset: MediaTime;
|
||||
trackSection: GroupTrackSection;
|
||||
sectionIndex: number;
|
||||
displayIndex: number;
|
||||
@@ -26,7 +27,7 @@ export interface PlannedElementMove {
|
||||
sourceTrackId: string;
|
||||
targetTrackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
newStartTime: MediaTime;
|
||||
}
|
||||
|
||||
export interface GroupMoveResult {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import {
|
||||
getSourceSpanAtClipTime,
|
||||
getTimelineDurationForSourceSpan,
|
||||
} from "@/retime";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
clampMediaTime,
|
||||
maxMediaTime,
|
||||
type MediaTime,
|
||||
mediaTime,
|
||||
minMediaTime,
|
||||
roundFrameTicks,
|
||||
roundMediaTime,
|
||||
subMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import type {
|
||||
ComputeGroupResizeArgs,
|
||||
GroupResizeMember,
|
||||
@@ -15,63 +26,88 @@ import type {
|
||||
export function computeGroupResize({
|
||||
members,
|
||||
side,
|
||||
deltaTicks,
|
||||
deltaTime,
|
||||
fps,
|
||||
}: ComputeGroupResizeArgs): GroupResizeResult {
|
||||
const minDuration = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
const minimumDeltaTicks = Math.max(
|
||||
...members.map((member) =>
|
||||
getMinimumAllowedDeltaTicks({
|
||||
if (members.length === 0) {
|
||||
return { deltaTime: ZERO_MEDIA_TIME, updates: [] };
|
||||
}
|
||||
|
||||
const minDuration = mediaTime({
|
||||
ticks: Math.round((TICKS_PER_SECOND * fps.denominator) / fps.numerator),
|
||||
});
|
||||
let minimumDeltaTime = getMinimumAllowedDeltaTime({
|
||||
member: members[0],
|
||||
side,
|
||||
minDuration,
|
||||
});
|
||||
let maximumDeltaTime = getMaximumAllowedDeltaTime({
|
||||
member: members[0],
|
||||
side,
|
||||
minDuration,
|
||||
});
|
||||
|
||||
for (const member of members.slice(1)) {
|
||||
minimumDeltaTime = maxMediaTime({
|
||||
a: minimumDeltaTime,
|
||||
b: getMinimumAllowedDeltaTime({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const maximumDeltaTicks = Math.min(
|
||||
...members.map((member) =>
|
||||
getMaximumAllowedDeltaTicks({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const clampedDeltaTicks =
|
||||
minimumDeltaTicks > maximumDeltaTicks
|
||||
? minimumDeltaTicks
|
||||
: Math.min(maximumDeltaTicks, Math.max(minimumDeltaTicks, deltaTicks));
|
||||
});
|
||||
const memberMaximum = getMaximumAllowedDeltaTime({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
});
|
||||
if (memberMaximum !== null) {
|
||||
maximumDeltaTime =
|
||||
maximumDeltaTime === null
|
||||
? memberMaximum
|
||||
: minMediaTime({ a: maximumDeltaTime, b: memberMaximum });
|
||||
}
|
||||
}
|
||||
|
||||
const clampedDeltaTime =
|
||||
maximumDeltaTime === null
|
||||
? maxMediaTime({ a: minimumDeltaTime, b: deltaTime })
|
||||
: clampMediaTime({
|
||||
time: deltaTime,
|
||||
min: minimumDeltaTime,
|
||||
max: maximumDeltaTime,
|
||||
});
|
||||
|
||||
// Snap the drag delta to a frame exactly once, then derive every patch
|
||||
// field from that single snapped value. This keeps the invariant
|
||||
// `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
|
||||
// delta is added on one side of the element and removed from the other,
|
||||
// so the rounding cancels by construction. Rounding each field
|
||||
// independently would break this — the individual rounds don't compose
|
||||
// when `sourceDuration` isn't frame-aligned.
|
||||
const snappedDeltaTicks =
|
||||
roundToFrame({ time: clampedDeltaTicks, rate: fps }) ?? clampedDeltaTicks;
|
||||
// so the rounding cancels by construction. Per-field rounding (the old
|
||||
// approach) couldn't preserve this because the individual rounds don't
|
||||
// compose when `sourceDuration` isn't frame-aligned.
|
||||
const snappedDeltaTime = mediaTime({
|
||||
ticks: roundFrameTicks({ ticks: clampedDeltaTime, fps }),
|
||||
});
|
||||
// Re-clamp after rounding. Bounds derived from other elements are
|
||||
// frame-aligned, so this is normally a no-op; at the source-extent limit
|
||||
// the bound may not be frame-aligned, and honouring the bound takes
|
||||
// precedence over frame alignment (you can't extend past real content).
|
||||
const finalDeltaTicks =
|
||||
minimumDeltaTicks > maximumDeltaTicks
|
||||
? minimumDeltaTicks
|
||||
: Math.min(
|
||||
maximumDeltaTicks,
|
||||
Math.max(minimumDeltaTicks, snappedDeltaTicks),
|
||||
);
|
||||
const finalDeltaTime =
|
||||
maximumDeltaTime === null
|
||||
? maxMediaTime({ a: minimumDeltaTime, b: snappedDeltaTime })
|
||||
: clampMediaTime({
|
||||
time: snappedDeltaTime,
|
||||
min: minimumDeltaTime,
|
||||
max: maximumDeltaTime,
|
||||
});
|
||||
|
||||
return {
|
||||
deltaTicks: Object.is(finalDeltaTicks, -0) ? 0 : finalDeltaTicks,
|
||||
deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime,
|
||||
updates: members.map((member) =>
|
||||
buildResizeUpdate({
|
||||
member,
|
||||
side,
|
||||
deltaTicks: finalDeltaTicks,
|
||||
deltaTime: finalDeltaTime,
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -80,27 +116,30 @@ export function computeGroupResize({
|
||||
function buildResizeUpdate({
|
||||
member,
|
||||
side,
|
||||
deltaTicks,
|
||||
deltaTime,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
side: ResizeSide;
|
||||
deltaTicks: number;
|
||||
deltaTime: MediaTime;
|
||||
}): GroupResizeUpdate {
|
||||
const sourceDelta = getSourceDeltaForClipDelta({
|
||||
member,
|
||||
clipDelta: deltaTicks,
|
||||
clipDelta: deltaTime,
|
||||
});
|
||||
|
||||
if (side === "left") {
|
||||
return {
|
||||
trackId: member.trackId,
|
||||
elementId: member.elementId,
|
||||
patch: {
|
||||
trimStart: Math.max(0, member.trimStart + sourceDelta),
|
||||
trimEnd: member.trimEnd,
|
||||
startTime: member.startTime + deltaTicks,
|
||||
duration: member.duration - deltaTicks,
|
||||
},
|
||||
patch: {
|
||||
trimStart: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: addMediaTime({ a: member.trimStart, b: sourceDelta }),
|
||||
}),
|
||||
trimEnd: member.trimEnd,
|
||||
startTime: addMediaTime({ a: member.startTime, b: deltaTime }),
|
||||
duration: subMediaTime({ a: member.duration, b: deltaTime }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,72 +148,95 @@ function buildResizeUpdate({
|
||||
elementId: member.elementId,
|
||||
patch: {
|
||||
trimStart: member.trimStart,
|
||||
trimEnd: Math.max(0, member.trimEnd - sourceDelta),
|
||||
trimEnd: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({ a: member.trimEnd, b: sourceDelta }),
|
||||
}),
|
||||
startTime: member.startTime,
|
||||
duration: member.duration + deltaTicks,
|
||||
duration: addMediaTime({ a: member.duration, b: deltaTime }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getMinimumAllowedDeltaTicks({
|
||||
function getMinimumAllowedDeltaTime({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
side: ResizeSide;
|
||||
minDuration: number;
|
||||
}): number {
|
||||
minDuration: MediaTime;
|
||||
}): MediaTime {
|
||||
if (side === "right") {
|
||||
return minDuration - member.duration;
|
||||
return subMediaTime({ a: minDuration, b: member.duration });
|
||||
}
|
||||
|
||||
const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
|
||||
? member.leftNeighborBound - member.startTime
|
||||
: -member.startTime;
|
||||
const leftNeighborFloor =
|
||||
member.leftNeighborBound !== null
|
||||
? subMediaTime({ a: member.leftNeighborBound, b: member.startTime })
|
||||
: subMediaTime({ a: ZERO_MEDIA_TIME, b: member.startTime });
|
||||
if (member.sourceDuration == null) {
|
||||
return leftNeighborFloor;
|
||||
}
|
||||
|
||||
const maximumSourceExtension =
|
||||
getDurationForVisibleSourceSpan({
|
||||
const maximumSourceExtension = subMediaTime({
|
||||
a: getDurationForVisibleSourceSpan({
|
||||
member,
|
||||
sourceSpan:
|
||||
getVisibleSourceSpanForDuration({
|
||||
sourceSpan: addMediaTime({
|
||||
a: getVisibleSourceSpanForDuration({
|
||||
member,
|
||||
duration: member.duration,
|
||||
}) + member.trimStart,
|
||||
}) - member.duration;
|
||||
return Math.max(leftNeighborFloor, -maximumSourceExtension);
|
||||
}),
|
||||
b: member.trimStart,
|
||||
}),
|
||||
}),
|
||||
b: member.duration,
|
||||
});
|
||||
return maxMediaTime({
|
||||
a: leftNeighborFloor,
|
||||
b: subMediaTime({ a: ZERO_MEDIA_TIME, b: maximumSourceExtension }),
|
||||
});
|
||||
}
|
||||
|
||||
function getMaximumAllowedDeltaTicks({
|
||||
function getMaximumAllowedDeltaTime({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
side: ResizeSide;
|
||||
minDuration: number;
|
||||
}): number {
|
||||
minDuration: MediaTime;
|
||||
}): MediaTime | null {
|
||||
if (side === "left") {
|
||||
return member.duration - minDuration;
|
||||
return subMediaTime({ a: member.duration, b: minDuration });
|
||||
}
|
||||
|
||||
const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
|
||||
? member.rightNeighborBound - (member.startTime + member.duration)
|
||||
: Infinity;
|
||||
const rightNeighborCeiling =
|
||||
member.rightNeighborBound === null
|
||||
? null
|
||||
: subMediaTime({
|
||||
a: member.rightNeighborBound,
|
||||
b: addMediaTime({ a: member.startTime, b: member.duration }),
|
||||
});
|
||||
if (member.sourceDuration == null) {
|
||||
return rightNeighborCeiling;
|
||||
}
|
||||
|
||||
const maximumVisibleSourceSpan =
|
||||
getSourceDuration({ member }) - member.trimStart;
|
||||
const maximumVisibleSourceSpan = subMediaTime({
|
||||
a: getSourceDuration({ member }),
|
||||
b: member.trimStart,
|
||||
});
|
||||
const maximumDuration = getDurationForVisibleSourceSpan({
|
||||
member,
|
||||
sourceSpan: maximumVisibleSourceSpan,
|
||||
});
|
||||
return Math.min(rightNeighborCeiling, maximumDuration - member.duration);
|
||||
const sourceDurationCeiling = subMediaTime({
|
||||
a: maximumDuration,
|
||||
b: member.duration,
|
||||
});
|
||||
return rightNeighborCeiling === null
|
||||
? sourceDurationCeiling
|
||||
: minMediaTime({ a: rightNeighborCeiling, b: sourceDurationCeiling });
|
||||
}
|
||||
|
||||
function getSourceDeltaForClipDelta({
|
||||
@@ -182,21 +244,23 @@ function getSourceDeltaForClipDelta({
|
||||
clipDelta,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
clipDelta: number;
|
||||
}): number {
|
||||
clipDelta: MediaTime;
|
||||
}): MediaTime {
|
||||
if (!member.retime) {
|
||||
return clipDelta;
|
||||
}
|
||||
|
||||
return clipDelta >= 0
|
||||
? getSourceSpanAtClipTime({
|
||||
clipTime: clipDelta,
|
||||
retime: member.retime,
|
||||
})
|
||||
: -getSourceSpanAtClipTime({
|
||||
clipTime: Math.abs(clipDelta),
|
||||
retime: member.retime,
|
||||
});
|
||||
const sourceDelta =
|
||||
clipDelta >= 0
|
||||
? getSourceSpanAtClipTime({
|
||||
clipTime: clipDelta,
|
||||
retime: member.retime,
|
||||
})
|
||||
: -getSourceSpanAtClipTime({
|
||||
clipTime: Math.abs(clipDelta),
|
||||
retime: member.retime,
|
||||
});
|
||||
return roundMediaTime({ time: sourceDelta });
|
||||
}
|
||||
|
||||
function getVisibleSourceSpanForDuration({
|
||||
@@ -204,15 +268,17 @@ function getVisibleSourceSpanForDuration({
|
||||
duration,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
duration: number;
|
||||
}): number {
|
||||
duration: MediaTime;
|
||||
}): MediaTime {
|
||||
if (!member.retime) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
return getSourceSpanAtClipTime({
|
||||
clipTime: duration,
|
||||
retime: member.retime,
|
||||
return roundMediaTime({
|
||||
time: getSourceSpanAtClipTime({
|
||||
clipTime: duration,
|
||||
retime: member.retime,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,29 +287,33 @@ function getDurationForVisibleSourceSpan({
|
||||
sourceSpan,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
sourceSpan: number;
|
||||
}): number {
|
||||
sourceSpan: MediaTime;
|
||||
}): MediaTime {
|
||||
if (!member.retime) {
|
||||
return sourceSpan;
|
||||
}
|
||||
|
||||
return getTimelineDurationForSourceSpan({
|
||||
sourceSpan,
|
||||
retime: member.retime,
|
||||
return roundMediaTime({
|
||||
time: getTimelineDurationForSourceSpan({
|
||||
sourceSpan,
|
||||
retime: member.retime,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function getSourceDuration({ member }: { member: GroupResizeMember }): number {
|
||||
if (typeof member.sourceDuration === "number") {
|
||||
function getSourceDuration({ member }: { member: GroupResizeMember }): MediaTime {
|
||||
if (member.sourceDuration != null) {
|
||||
return member.sourceDuration;
|
||||
}
|
||||
|
||||
return (
|
||||
member.trimStart +
|
||||
getVisibleSourceSpanForDuration({
|
||||
return addMediaTime({
|
||||
a: addMediaTime({
|
||||
a: member.trimStart,
|
||||
b: getVisibleSourceSpanForDuration({
|
||||
member,
|
||||
duration: member.duration,
|
||||
}) +
|
||||
member.trimEnd
|
||||
);
|
||||
}),
|
||||
}),
|
||||
b: member.trimEnd,
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user