refactor: split animation helpers by domain
This commit is contained in:
commit
6b1f38a9a3
|
|
@ -3820,7 +3820,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opencut-wasm"
|
name = "opencut-wasm"
|
||||||
version = "0.2.9"
|
version = "0.2.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bridge",
|
"bridge",
|
||||||
"compositor",
|
"compositor",
|
||||||
|
|
|
||||||
311
README.md
311
README.md
|
|
@ -1,167 +1,144 @@
|
||||||
<table width="100%">
|
|
||||||
<tr>
|
| | |
|
||||||
<td align="left" width="120">
|
| --- | ---------------------------------------------------------------------- |
|
||||||
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
|
| | OpenCutA free, open-source video editor for web, desktop, and mobile. |
|
||||||
</td>
|
|
||||||
<td align="right">
|
|
||||||
<h1>OpenCut</h1>
|
## Sponsors
|
||||||
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
|
|
||||||
</td>
|
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.
|
||||||
</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.
|
## Why?
|
||||||
|
|
||||||
<a href="https://vercel.com/oss">
|
- **Privacy**: Your videos stay on your device
|
||||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
- **Free features**: Most basic CapCut features are now paywalled
|
||||||
</a>
|
- **Simple**: People want editors that are easy to use - CapCut proved that
|
||||||
|
|
||||||
<a href="https://fal.ai">
|
## Project Structure
|
||||||
<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>
|
- `apps/web/`: Next.js web application
|
||||||
|
- `apps/desktop/`: Native desktop app built with GPUI (in progress)
|
||||||
## Why?
|
- `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
|
||||||
- **Privacy**: Your videos stay on your device
|
|
||||||
- **Free features**: Most basic CapCut features are now paywalled
|
## Getting Started
|
||||||
- **Simple**: People want editors that are easy to use - CapCut proved that
|
|
||||||
|
### Prerequisites
|
||||||
## Project Structure
|
|
||||||
|
- [Bun](https://bun.sh/docs/installation)
|
||||||
- `apps/web/`: Next.js web application
|
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||||
- `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.
|
> **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.
|
||||||
- `docs/`: Architecture and subsystem documentation
|
|
||||||
|
### Setup
|
||||||
## Getting Started
|
|
||||||
|
1. Fork and clone the repository
|
||||||
### Prerequisites
|
2. Copy the environment file:
|
||||||
|
```bash
|
||||||
- [Bun](https://bun.sh/docs/installation)
|
# Unix/Linux/Mac
|
||||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
cp apps/web/.env.example apps/web/.env.local
|
||||||
|
|
||||||
> **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.
|
# Windows PowerShell
|
||||||
|
Copy-Item apps/web/.env.example apps/web/.env.local
|
||||||
### Setup
|
```
|
||||||
|
3. Start the database and Redis:
|
||||||
1. Fork and clone the repository
|
```bash
|
||||||
|
docker compose up -d db redis serverless-redis-http
|
||||||
2. Copy the environment file:
|
```
|
||||||
|
4. Install dependencies and start the dev server:
|
||||||
```bash
|
```bash
|
||||||
# Unix/Linux/Mac
|
bun install
|
||||||
cp apps/web/.env.example apps/web/.env.local
|
bun dev:web
|
||||||
|
```
|
||||||
# Windows PowerShell
|
|
||||||
Copy-Item apps/web/.env.example apps/web/.env.local
|
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.
|
||||||
3. Start the database and Redis:
|
|
||||||
|
### Desktop setup
|
||||||
```bash
|
|
||||||
docker compose up -d db redis serverless-redis-http
|
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.
|
||||||
4. Install dependencies and start the dev server:
|
|
||||||
|
### Local WASM development
|
||||||
```bash
|
|
||||||
bun install
|
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
||||||
bun dev:web
|
|
||||||
```
|
**Prerequisites** — install these once before anything else:
|
||||||
|
|
||||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
```bash
|
||||||
|
# Rust toolchain
|
||||||
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
|
||||||
### Desktop setup
|
# build the WASM package
|
||||||
|
cargo install wasm-pack
|
||||||
Desktop is opt-in. If you're only working on the web app, skip this entirely.
|
|
||||||
|
# reruns the build on file changes, used by bun dev:wasm
|
||||||
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.
|
cargo install cargo-watch
|
||||||
|
```
|
||||||
### Local WASM development
|
|
||||||
|
1. Build the package once from the repo root:
|
||||||
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
```bash
|
||||||
|
bun run build:wasm
|
||||||
**Prerequisites** — install these once before anything else:
|
```
|
||||||
|
2. Register the generated package for linking:
|
||||||
```bash
|
```bash
|
||||||
# Rust toolchain
|
cd rust/wasm/pkg
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
bun link
|
||||||
|
```
|
||||||
# build the WASM package
|
3. Link `apps/web` to the local package:
|
||||||
cargo install wasm-pack
|
```bash
|
||||||
|
cd apps/web
|
||||||
# reruns the build on file changes, used by bun dev:wasm
|
bun link opencut-wasm
|
||||||
cargo install cargo-watch
|
```
|
||||||
```
|
4. Rebuild on changes while you work:
|
||||||
|
```bash
|
||||||
1. Build the package once from the repo root:
|
bun dev:wasm
|
||||||
|
```
|
||||||
```bash
|
|
||||||
bun run build:wasm
|
To switch `apps/web` back to the published package, run:
|
||||||
```
|
|
||||||
|
```bash
|
||||||
2. Register the generated package for linking:
|
cd apps/web
|
||||||
|
bun add opencut-wasm
|
||||||
```bash
|
```
|
||||||
cd rust/wasm/pkg
|
|
||||||
bun link
|
### Self-Hosting with Docker
|
||||||
```
|
|
||||||
|
To run everything (including a production build of the app) in Docker:
|
||||||
3. Link `apps/web` to the local package:
|
|
||||||
|
```bash
|
||||||
```bash
|
docker compose up -d
|
||||||
cd apps/web
|
```
|
||||||
bun link opencut-wasm
|
|
||||||
```
|
The app will be available at [http://localhost:3100](http://localhost:3100).
|
||||||
|
|
||||||
4. Rebuild on changes while you work:
|
## Contributing
|
||||||
|
|
||||||
```bash
|
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
|
||||||
bun dev:wasm
|
|
||||||
```
|
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
|
||||||
|
|
||||||
To switch `apps/web` back to the published package, run:
|
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
|
||||||
|
|
||||||
```bash
|
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
|
||||||
cd apps/web
|
|
||||||
bun add opencut-wasm
|
**Quick start for contributors:**
|
||||||
```
|
|
||||||
|
- Fork the repo and clone locally
|
||||||
### Self-Hosting with Docker
|
- Follow the setup instructions in CONTRIBUTING.md
|
||||||
|
- Working on `apps/desktop`? See `[apps/desktop/README.md](apps/desktop/README.md)` for setup
|
||||||
To run everything (including a production build of the app) in Docker:
|
- Create a feature branch and submit a PR
|
||||||
|
|
||||||
```bash
|
## License
|
||||||
docker compose up -d
|
|
||||||
```
|
[MIT LICENSE](LICENSE)
|
||||||
|
|
||||||
The app will be available at [http://localhost:3100](http://localhost:3100).
|
---
|
||||||
|
|
||||||
## Contributing
|
Star History Chart
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||

|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"opencut-wasm": "^0.2.9",
|
"opencut-wasm": "^0.2.10",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import type { ParamValues } from "@/params";
|
|
||||||
import type { Effect } from "@/effects/types";
|
|
||||||
import type {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
EffectParamPath,
|
EffectParamPath,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
|
import type { ParamValues } from "@/params";
|
||||||
import { removeElementKeyframe } from "./keyframes";
|
import { removeElementKeyframe } from "./keyframes";
|
||||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
|
|
||||||
|
|
@ -56,23 +55,26 @@ export function parseEffectParamPath({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveEffectParamsAtTime({
|
export function resolveEffectParamsAtTime({
|
||||||
effect,
|
effectId,
|
||||||
|
params,
|
||||||
animations,
|
animations,
|
||||||
localTime,
|
localTime,
|
||||||
}: {
|
}: {
|
||||||
effect: Effect;
|
effectId: string;
|
||||||
|
params: ParamValues;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): ParamValues {
|
}): ParamValues {
|
||||||
|
const safeLocalTime = Math.max(0, localTime);
|
||||||
const resolved: ParamValues = {};
|
const resolved: ParamValues = {};
|
||||||
|
|
||||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
for (const [paramKey, staticValue] of Object.entries(params)) {
|
||||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
const path = buildEffectParamPath({ effectId, paramKey });
|
||||||
resolved[paramKey] = animations?.bindings[path]
|
resolved[paramKey] = animations?.bindings[path]
|
||||||
? resolveAnimationPathValueAtTime({
|
? resolveAnimationPathValueAtTime({
|
||||||
animations,
|
animations,
|
||||||
propertyPath: path,
|
propertyPath: path,
|
||||||
localTime,
|
localTime: safeLocalTime,
|
||||||
fallbackValue: staticValue,
|
fallbackValue: staticValue,
|
||||||
})
|
})
|
||||||
: staticValue;
|
: staticValue;
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,7 @@ import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
GraphicParamPath,
|
GraphicParamPath,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import type { ParamValues } from "@/params";
|
import type { ParamDefinition, ParamValues } from "@/params";
|
||||||
import {
|
|
||||||
getGraphicDefinition,
|
|
||||||
resolveGraphicParams,
|
|
||||||
} from "@/graphics";
|
|
||||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
|
|
||||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||||
|
|
@ -39,33 +35,29 @@ export function parseGraphicParamPath({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGraphicParamsAtTime({
|
export function resolveGraphicParamsAtTime({
|
||||||
element,
|
params,
|
||||||
|
definitions,
|
||||||
|
animations,
|
||||||
localTime,
|
localTime,
|
||||||
}: {
|
}: {
|
||||||
element: {
|
params: ParamValues;
|
||||||
definitionId: string;
|
definitions: ParamDefinition[];
|
||||||
params: ParamValues;
|
animations?: ElementAnimations;
|
||||||
animations?: ElementAnimations;
|
|
||||||
};
|
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): ParamValues {
|
}): ParamValues {
|
||||||
const definition = getGraphicDefinition({
|
const resolved: ParamValues = { ...params };
|
||||||
definitionId: element.definitionId,
|
|
||||||
});
|
|
||||||
const baseParams = resolveGraphicParams(definition, element.params);
|
|
||||||
const resolved: ParamValues = { ...baseParams };
|
|
||||||
|
|
||||||
for (const param of definition.params) {
|
for (const param of definitions) {
|
||||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||||
if (!element.animations?.bindings[path]) {
|
if (!animations?.bindings[path]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved[param.key] = resolveAnimationPathValueAtTime({
|
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||||
animations: element.animations,
|
animations,
|
||||||
propertyPath: path,
|
propertyPath: path,
|
||||||
localTime: Math.max(0, localTime),
|
localTime: Math.max(0, localTime),
|
||||||
fallbackValue: baseParams[param.key] ?? param.default,
|
fallbackValue: params[param.key] ?? param.default,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,24 +23,8 @@ export {
|
||||||
export {
|
export {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveAnimationPathValueAtTime,
|
resolveAnimationPathValueAtTime,
|
||||||
resolveColorAtTime,
|
|
||||||
resolveNumberAtTime,
|
|
||||||
resolveOpacityAtTime,
|
|
||||||
resolveTransformAtTime,
|
|
||||||
} from "./resolve";
|
} from "./resolve";
|
||||||
|
|
||||||
export {
|
|
||||||
coerceAnimationValueForProperty,
|
|
||||||
getAnimationPropertyDefinition,
|
|
||||||
getDefaultInterpolationForProperty,
|
|
||||||
getElementBaseValueForProperty,
|
|
||||||
isAnimationPropertyPath,
|
|
||||||
supportsAnimationProperty,
|
|
||||||
type AnimationPropertyDefinition,
|
|
||||||
type NumericSpec,
|
|
||||||
withElementBaseValueForProperty,
|
|
||||||
} from "./property-registry";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getElementKeyframes,
|
getElementKeyframes,
|
||||||
getKeyframeById,
|
getKeyframeById,
|
||||||
|
|
@ -75,15 +59,6 @@ export {
|
||||||
resolveEffectParamsAtTime,
|
resolveEffectParamsAtTime,
|
||||||
} from "./effect-param-channel";
|
} from "./effect-param-channel";
|
||||||
|
|
||||||
export {
|
|
||||||
isAnimationPath,
|
|
||||||
coerceAnimationValueForParam,
|
|
||||||
resolveAnimationTarget,
|
|
||||||
getParamValueKind,
|
|
||||||
getParamDefaultInterpolation,
|
|
||||||
type AnimationPathDescriptor,
|
|
||||||
} from "./target-resolver";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getGroupKeyframesAtTime,
|
getGroupKeyframesAtTime,
|
||||||
hasGroupKeyframeAtTime,
|
hasGroupKeyframeAtTime,
|
||||||
|
|
@ -94,3 +69,19 @@ export {
|
||||||
type EasingMode,
|
type EasingMode,
|
||||||
getEasingModeForKind,
|
getEasingModeForKind,
|
||||||
} from "./binding-values";
|
} from "./binding-values";
|
||||||
|
|
||||||
|
export {
|
||||||
|
isAnimationPath,
|
||||||
|
isAnimationPropertyPath,
|
||||||
|
} from "./path";
|
||||||
|
|
||||||
|
export {
|
||||||
|
coerceAnimationValueForProperty,
|
||||||
|
getAnimationPropertyDefinition,
|
||||||
|
getDefaultInterpolationForProperty,
|
||||||
|
getElementBaseValueForProperty,
|
||||||
|
supportsAnimationProperty,
|
||||||
|
type AnimationPropertyDefinition,
|
||||||
|
type NumericSpec,
|
||||||
|
withElementBaseValueForProperty,
|
||||||
|
} from "./property-registry";
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import type {
|
||||||
ScalarAnimationKey,
|
ScalarAnimationKey,
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import { clamp } from "@/utils/math";
|
|
||||||
import { mediaTime } from "@/wasm";
|
import { mediaTime } from "@/wasm";
|
||||||
import {
|
import {
|
||||||
getBezierPoint,
|
getBezierPoint,
|
||||||
|
|
@ -16,6 +15,7 @@ import {
|
||||||
getDefaultRightHandle,
|
getDefaultRightHandle,
|
||||||
solveBezierProgressForTime,
|
solveBezierProgressForTime,
|
||||||
} from "./bezier";
|
} from "./bezier";
|
||||||
|
import { clamp } from "@/utils/math";
|
||||||
|
|
||||||
function byTimeAscending({
|
function byTimeAscending({
|
||||||
leftTime,
|
leftTime,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
getChannelValueAtTime,
|
getChannelValueAtTime,
|
||||||
getScalarSegmentInterpolation,
|
getScalarSegmentInterpolation,
|
||||||
} from "./interpolation";
|
} from "./interpolation";
|
||||||
import { isAnimationPath } from "./target-resolver";
|
import { isAnimationPath } from "./path";
|
||||||
|
|
||||||
function getBindingFallbackValue({
|
function getBindingFallbackValue({
|
||||||
channel,
|
channel,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import type {
|
||||||
ScalarCurveKeyframePatch,
|
ScalarCurveKeyframePatch,
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import { generateUUID } from "@/utils/id";
|
|
||||||
import {
|
import {
|
||||||
cloneAnimationBinding,
|
cloneAnimationBinding,
|
||||||
createAnimationBinding,
|
createAnimationBinding,
|
||||||
|
|
@ -41,6 +40,7 @@ import {
|
||||||
subMediaTime,
|
subMediaTime,
|
||||||
ZERO_MEDIA_TIME,
|
ZERO_MEDIA_TIME,
|
||||||
} from "@/wasm";
|
} from "@/wasm";
|
||||||
|
import { generateUUID } from "@/utils/id";
|
||||||
|
|
||||||
function isNearlySameTime({
|
function isNearlySameTime({
|
||||||
leftTime,
|
leftTime,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
import type {
|
||||||
AnimationColorPropertyPath,
|
|
||||||
AnimationNumericPropertyPath,
|
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationPropertyPath,
|
|
||||||
AnimationValueForPath,
|
AnimationValueForPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import type { Transform } from "@/rendering";
|
|
||||||
import {
|
import {
|
||||||
type AnimationComponentValue,
|
type AnimationComponentValue,
|
||||||
composeAnimationValue,
|
composeAnimationValue,
|
||||||
|
|
@ -37,107 +33,6 @@ export function getElementLocalTime({
|
||||||
return localTime;
|
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>({
|
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,13 @@ export interface AnimationPropertyValueMap {
|
||||||
"background.offsetY": number;
|
"background.offsetY": number;
|
||||||
"background.cornerRadius": 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> =
|
export type AnimationValueForPath<TPath extends AnimationPath> =
|
||||||
TPath extends AnimationPropertyPath
|
TPath extends AnimationPropertyPath
|
||||||
? AnimationPropertyValueMap[TPath]
|
? AnimationPropertyValueMap[TPath]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
resolveAnimationTarget,
|
|
||||||
updateScalarKeyframeCurve,
|
updateScalarKeyframeCurve,
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
|
|
@ -9,6 +8,7 @@ import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import type { KeyframeClipboardItem } from "@/clipboard";
|
import type { KeyframeClipboardItem } from "@/clipboard";
|
||||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import {
|
import {
|
||||||
addMediaTime,
|
addMediaTime,
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
removeElementKeyframe,
|
removeElementKeyframe,
|
||||||
resolveAnimationTarget,
|
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import type { AnimationPath, AnimationValue } from "@/animation/types";
|
import type { AnimationPath, AnimationValue } from "@/animation/types";
|
||||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
|
|
||||||
function removeKeyframeAndPersist({
|
function removeKeyframeAndPersist({
|
||||||
element,
|
element,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation";
|
import { retimeElementKeyframe } from "@/animation";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import type { AnimationPath } from "@/animation/types";
|
import type { AnimationPath } from "@/animation/types";
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
minMediaTime,
|
minMediaTime,
|
||||||
ZERO_MEDIA_TIME,
|
ZERO_MEDIA_TIME,
|
||||||
} from "@/wasm";
|
} from "@/wasm";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
|
|
||||||
export class RetimeKeyframeCommand extends Command {
|
export class RetimeKeyframeCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
resolveAnimationTarget,
|
|
||||||
updateScalarKeyframeCurve,
|
updateScalarKeyframeCurve,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import type {
|
import type {
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
ScalarCurveKeyframePatch,
|
ScalarCurveKeyframePatch,
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import {
|
import {
|
||||||
buildEffectParamPath,
|
buildEffectParamPath,
|
||||||
resolveAnimationTarget,
|
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import { isVisualElement } from "@/timeline/element-utils";
|
import { isVisualElement } from "@/timeline/element-utils";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import type { AnimationInterpolation } from "@/animation/types";
|
import type { AnimationInterpolation } from "@/animation/types";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation";
|
import { upsertPathKeyframe } from "@/animation";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import type {
|
import type {
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import {
|
import {
|
||||||
buildGraphicParamPath,
|
buildGraphicParamPath,
|
||||||
coerceAnimationValueForParam,
|
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
getParamDefaultInterpolation,
|
|
||||||
getParamValueKind,
|
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import type {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
|
import {
|
||||||
|
coerceAnimationParamValue,
|
||||||
|
getAnimationParamDefaultInterpolation,
|
||||||
|
getAnimationParamValueKind,
|
||||||
|
} from "@/animation/animated-params";
|
||||||
import type { ParamDefinition } from "@/params";
|
import type { ParamDefinition } from "@/params";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import type { MediaTime } from "@/wasm";
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
@ -80,12 +82,12 @@ export function useKeyframedParamProperty({
|
||||||
propertyPath,
|
propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value,
|
value,
|
||||||
kind: getParamValueKind({ param }),
|
kind: getAnimationParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({
|
defaultInterpolation: getAnimationParamDefaultInterpolation({
|
||||||
param,
|
param,
|
||||||
}),
|
}),
|
||||||
coerceValue: ({ value: nextValue }) =>
|
coerceValue: ({ value: nextValue }) =>
|
||||||
coerceAnimationValueForParam({
|
coerceAnimationParamValue({
|
||||||
param,
|
param,
|
||||||
value: nextValue,
|
value: nextValue,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export class PlaybackManager {
|
||||||
this.playbackStartTime = this.currentTime;
|
this.playbackStartTime = this.currentTime;
|
||||||
}
|
}
|
||||||
this.notify();
|
this.notify();
|
||||||
this.dispatchSeekEvent(this.currentTime);
|
this.notifySeek(this.currentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume({ volume }: { volume: number }): void {
|
setVolume({ volume }: { volume: number }): void {
|
||||||
|
|
@ -227,9 +227,9 @@ export class PlaybackManager {
|
||||||
this.pause();
|
this.pause();
|
||||||
this.currentTime = maxTime;
|
this.currentTime = maxTime;
|
||||||
this.notify();
|
this.notify();
|
||||||
this.notifySeek(maxTime);
|
this.notifySeek(maxTime);
|
||||||
this.dispatchSeekEvent(maxTime);
|
this.dispatchSeekEvent(maxTime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentTime = newTime;
|
this.currentTime = newTime;
|
||||||
|
|
@ -247,23 +247,11 @@ export class PlaybackManager {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("playback-seek", {
|
|
||||||
detail: { time },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatchUpdateEvent(time: MediaTime): void {
|
private dispatchUpdateEvent(time: MediaTime): void {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("playback-update", {
|
|
||||||
detail: { time },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,9 @@ import type {
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveAnimationTarget,
|
|
||||||
resolveAnimationPathValueAtTime,
|
resolveAnimationPathValueAtTime,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import { BatchCommand } from "@/commands";
|
import { BatchCommand } from "@/commands";
|
||||||
import {
|
import {
|
||||||
AddTrackCommand,
|
AddTrackCommand,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* Lightweight rolling perf instrumentation for the render pipeline.
|
||||||
|
*
|
||||||
|
* Toggle at runtime from the devtools console:
|
||||||
|
* window.__renderPerf = true
|
||||||
|
*
|
||||||
|
* Every FLUSH_EVERY frames the aggregator dumps:
|
||||||
|
* - per-span timing summary (count / mean / p50 / p95 / max, in ms)
|
||||||
|
* - per-counter totals (uploads, canvas allocations by kind, etc.)
|
||||||
|
*
|
||||||
|
* Zero overhead when disabled: `isRenderPerfEnabled()` short-circuits before
|
||||||
|
* any recording happens, so call sites only pay for a global read.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SpanSample = number;
|
||||||
|
|
||||||
|
type SpanStats = {
|
||||||
|
samples: SpanSample[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CounterStats = {
|
||||||
|
total: number;
|
||||||
|
frames: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FLUSH_EVERY = 60;
|
||||||
|
|
||||||
|
const spans = new Map<string, SpanStats>();
|
||||||
|
const counters = new Map<string, CounterStats>();
|
||||||
|
const pendingCountersThisFrame = new Map<string, number>();
|
||||||
|
|
||||||
|
let framesSinceFlush = 0;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__renderPerf?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRenderPerfEnabled(): boolean {
|
||||||
|
return typeof window !== "undefined" && window.__renderPerf === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordSpan({
|
||||||
|
name,
|
||||||
|
durationMs,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
durationMs: number;
|
||||||
|
}): void {
|
||||||
|
if (!isRenderPerfEnabled()) return;
|
||||||
|
let stats = spans.get(name);
|
||||||
|
if (!stats) {
|
||||||
|
stats = { samples: [] };
|
||||||
|
spans.set(name, stats);
|
||||||
|
}
|
||||||
|
stats.samples.push(durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureSpanAsync<T>({
|
||||||
|
name,
|
||||||
|
fn,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
fn: () => Promise<T>;
|
||||||
|
}): Promise<T> {
|
||||||
|
if (!isRenderPerfEnabled()) return fn();
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
recordSpan({ name, durationMs: performance.now() - start });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measureSpanSync<T>({
|
||||||
|
name,
|
||||||
|
fn,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
fn: () => T;
|
||||||
|
}): T {
|
||||||
|
if (!isRenderPerfEnabled()) return fn();
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
recordSpan({ name, durationMs: performance.now() - start });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementCounter({
|
||||||
|
name,
|
||||||
|
by = 1,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
by?: number;
|
||||||
|
}): void {
|
||||||
|
if (!isRenderPerfEnabled()) return;
|
||||||
|
pendingCountersThisFrame.set(
|
||||||
|
name,
|
||||||
|
(pendingCountersThisFrame.get(name) ?? 0) + by,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls sub-span timings recorded inside the wasm `renderFrame` call and
|
||||||
|
* feeds them into the aggregator as ordinary spans.
|
||||||
|
*/
|
||||||
|
export function recordWasmFrameProfile(
|
||||||
|
entries: Array<{ name: string; durationMs: number }>,
|
||||||
|
): void {
|
||||||
|
if (!isRenderPerfEnabled()) return;
|
||||||
|
for (const entry of entries) {
|
||||||
|
recordSpan({ name: entry.name, durationMs: entry.durationMs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once per frame by the top of the render pipeline. Rolls the
|
||||||
|
* pending-frame counters into the aggregate and triggers a flush on cadence.
|
||||||
|
*/
|
||||||
|
export function onRenderPerfFrameComplete(): void {
|
||||||
|
if (!isRenderPerfEnabled()) return;
|
||||||
|
for (const [name, count] of pendingCountersThisFrame) {
|
||||||
|
let stats = counters.get(name);
|
||||||
|
if (!stats) {
|
||||||
|
stats = { total: 0, frames: 0 };
|
||||||
|
counters.set(name, stats);
|
||||||
|
}
|
||||||
|
stats.total += count;
|
||||||
|
stats.frames += 1;
|
||||||
|
}
|
||||||
|
pendingCountersThisFrame.clear();
|
||||||
|
|
||||||
|
framesSinceFlush += 1;
|
||||||
|
if (framesSinceFlush >= FLUSH_EVERY) {
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush(): void {
|
||||||
|
const spanRows: Array<Record<string, number | string>> = [];
|
||||||
|
for (const [name, stats] of spans) {
|
||||||
|
if (stats.samples.length === 0) continue;
|
||||||
|
const sorted = [...stats.samples].sort((a, b) => a - b);
|
||||||
|
const p = (q: number) =>
|
||||||
|
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))];
|
||||||
|
const sum = sorted.reduce((acc, v) => acc + v, 0);
|
||||||
|
spanRows.push({
|
||||||
|
span: name,
|
||||||
|
count: sorted.length,
|
||||||
|
meanMs: +(sum / sorted.length).toFixed(2),
|
||||||
|
p50Ms: +p(0.5).toFixed(2),
|
||||||
|
p95Ms: +p(0.95).toFixed(2),
|
||||||
|
maxMs: +sorted[sorted.length - 1].toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
spanRows.sort((a, b) => Number(b.meanMs) - Number(a.meanMs));
|
||||||
|
|
||||||
|
const counterRows: Array<Record<string, number | string>> = [];
|
||||||
|
for (const [name, stats] of counters) {
|
||||||
|
counterRows.push({
|
||||||
|
counter: name,
|
||||||
|
perFrame: +(stats.total / Math.max(1, stats.frames)).toFixed(2),
|
||||||
|
total: stats.total,
|
||||||
|
frames: stats.frames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
counterRows.sort((a, b) => Number(b.perFrame) - Number(a.perFrame));
|
||||||
|
|
||||||
|
console.groupCollapsed(
|
||||||
|
`[render-perf] summary over ${framesSinceFlush} frames`,
|
||||||
|
);
|
||||||
|
if (spanRows.length > 0) console.table(spanRows);
|
||||||
|
if (counterRows.length > 0) console.table(counterRows);
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
spans.clear();
|
||||||
|
counters.clear();
|
||||||
|
framesSinceFlush = 0;
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,9 @@ import {
|
||||||
useKeyframedParamProperty,
|
useKeyframedParamProperty,
|
||||||
type KeyframedParamPropertyResult,
|
type KeyframedParamPropertyResult,
|
||||||
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
||||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
|
||||||
import type { ParamDefinition, ParamValues } from "@/params";
|
import type { ParamDefinition, ParamValues } from "@/params";
|
||||||
import type { GraphicElement } from "@/timeline";
|
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 { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import {
|
import {
|
||||||
|
|
@ -49,7 +48,7 @@ export function GraphicTab({
|
||||||
});
|
});
|
||||||
|
|
||||||
const liveElement = renderElement as GraphicElement;
|
const liveElement = renderElement as GraphicElement;
|
||||||
const resolvedParams = resolveGraphicParamsAtTime({
|
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||||
element: liveElement,
|
element: liveElement,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
|
|
@ -104,7 +103,7 @@ function StrokeSection({
|
||||||
});
|
});
|
||||||
|
|
||||||
const liveElement = renderElement as GraphicElement;
|
const liveElement = renderElement as GraphicElement;
|
||||||
const resolvedParams = resolveGraphicParamsAtTime({
|
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||||
element: liveElement,
|
element: liveElement,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||||
|
import type { ElementAnimations } from "@/animation/types";
|
||||||
import { buildDefaultParamValues } from "@/params/registry";
|
import { buildDefaultParamValues } from "@/params/registry";
|
||||||
import type { ParamValues } from "@/params";
|
import type { ParamValues } from "@/params";
|
||||||
import { graphicsRegistry } from "./registry";
|
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({
|
export function buildGraphicPreviewUrl({
|
||||||
definitionId,
|
definitionId,
|
||||||
params,
|
params,
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,81 @@
|
||||||
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
import {
|
||||||
|
Input,
|
||||||
|
ALL_FORMATS,
|
||||||
|
BlobSource,
|
||||||
|
VideoSampleSink,
|
||||||
|
type VideoCodec,
|
||||||
|
} from "mediabunny";
|
||||||
import { createTimelineAudioBuffer } from "@/media/audio";
|
import { createTimelineAudioBuffer } from "@/media/audio";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
import type { MediaAsset } from "@/media/types";
|
import type { MediaAsset } from "@/media/types";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { TICKS_PER_SECOND } from "@/wasm";
|
||||||
|
import { renderThumbnailDataUrl } from "./thumbnail";
|
||||||
|
|
||||||
export async function getVideoInfo({
|
export type VideoFileData = {
|
||||||
videoFile,
|
|
||||||
}: {
|
|
||||||
videoFile: File;
|
|
||||||
}): Promise<{
|
|
||||||
duration: number;
|
duration: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
fps: number;
|
fps: number;
|
||||||
hasAudio: boolean;
|
hasAudio: boolean;
|
||||||
}> {
|
codec: VideoCodec | null;
|
||||||
|
canDecode: boolean;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function readVideoFile({
|
||||||
|
file,
|
||||||
|
}: {
|
||||||
|
file: File;
|
||||||
|
}): Promise<VideoFileData> {
|
||||||
const input = new Input({
|
const input = new Input({
|
||||||
source: new BlobSource(videoFile),
|
source: new BlobSource(file),
|
||||||
formats: ALL_FORMATS,
|
formats: ALL_FORMATS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const duration = await input.computeDuration();
|
try {
|
||||||
const videoTrack = await input.getPrimaryVideoTrack();
|
const duration = await input.computeDuration();
|
||||||
|
const videoTrack = await input.getPrimaryVideoTrack();
|
||||||
|
|
||||||
if (!videoTrack) {
|
if (!videoTrack) {
|
||||||
throw new Error("No video track found in the file");
|
throw new Error("No video track found in the file");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDecode = await videoTrack.canDecode();
|
||||||
|
const packetStats = await videoTrack.computePacketStats(100);
|
||||||
|
const audioTrack = await input.getPrimaryAudioTrack();
|
||||||
|
|
||||||
|
let thumbnailUrl: string | null = null;
|
||||||
|
if (canDecode) {
|
||||||
|
const sink = new VideoSampleSink(videoTrack);
|
||||||
|
const frame = await sink.getSample(1);
|
||||||
|
if (frame) {
|
||||||
|
try {
|
||||||
|
thumbnailUrl = renderThumbnailDataUrl({
|
||||||
|
width: videoTrack.displayWidth,
|
||||||
|
height: videoTrack.displayHeight,
|
||||||
|
draw: ({ context, width, height }) => {
|
||||||
|
frame.draw(context, 0, 0, width, height);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
frame.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
width: videoTrack.displayWidth,
|
||||||
|
height: videoTrack.displayHeight,
|
||||||
|
fps: packetStats.averagePacketRate,
|
||||||
|
hasAudio: audioTrack !== null,
|
||||||
|
codec: videoTrack.codec,
|
||||||
|
canDecode,
|
||||||
|
thumbnailUrl,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
input.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
const packetStats = await videoTrack.computePacketStats(100);
|
|
||||||
const fps = packetStats.averagePacketRate;
|
|
||||||
const audioTrack = await input.getPrimaryAudioTrack();
|
|
||||||
|
|
||||||
return {
|
|
||||||
duration,
|
|
||||||
width: videoTrack.displayWidth,
|
|
||||||
height: videoTrack.displayHeight,
|
|
||||||
fps,
|
|
||||||
hasAudio: audioTrack !== null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAMPLE_RATE = 44100;
|
const SAMPLE_RATE = 44100;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getMediaTypeFromFile } from "@/media/media-utils";
|
import { getMediaTypeFromFile } from "@/media/media-utils";
|
||||||
import { formatStorageBytes } from "@/services/storage/quota";
|
import { formatStorageBytes } from "@/services/storage/quota";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
import type { MediaAsset } from "@/media/types";
|
import type { MediaAsset } from "@/media/types";
|
||||||
import { getVideoInfo } from "./mediabunny";
|
import { readVideoFile } from "./mediabunny";
|
||||||
|
import type { VideoFileData } from "./mediabunny";
|
||||||
|
import { renderThumbnailDataUrl } from "./thumbnail";
|
||||||
|
|
||||||
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
|
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
|
||||||
|
|
||||||
const THUMBNAIL_MAX_WIDTH = 1280;
|
const getUnsupportedVideoDescription = ({
|
||||||
const THUMBNAIL_MAX_HEIGHT = 720;
|
codec,
|
||||||
|
}: {
|
||||||
|
codec: VideoFileData["codec"];
|
||||||
|
}): string => {
|
||||||
|
const codecLabel = codec ? codec.toUpperCase() : "this video codec";
|
||||||
|
|
||||||
|
return codec === "hevc"
|
||||||
|
? `${codecLabel} cannot be decoded in this browser, so this clip may not preview correctly. Convert it to H.264 MP4 or try importing it in Safari.`
|
||||||
|
: `${codecLabel} cannot be decoded in this browser, so this clip may not preview correctly. Convert it to H.264 MP4 and reimport it.`;
|
||||||
|
};
|
||||||
|
|
||||||
const getStorageLimitDescription = ({
|
const getStorageLimitDescription = ({
|
||||||
fileSize,
|
fileSize,
|
||||||
|
|
@ -29,103 +39,6 @@ const getStorageLimitDescription = ({
|
||||||
})} is safely available in browser storage.`;
|
})} is safely available in browser storage.`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getThumbnailSize = ({
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}): { width: number; height: number } => {
|
|
||||||
const aspectRatio = width / height;
|
|
||||||
let targetWidth = width;
|
|
||||||
let targetHeight = height;
|
|
||||||
|
|
||||||
if (targetWidth > THUMBNAIL_MAX_WIDTH) {
|
|
||||||
targetWidth = THUMBNAIL_MAX_WIDTH;
|
|
||||||
targetHeight = Math.round(targetWidth / aspectRatio);
|
|
||||||
}
|
|
||||||
if (targetHeight > THUMBNAIL_MAX_HEIGHT) {
|
|
||||||
targetHeight = THUMBNAIL_MAX_HEIGHT;
|
|
||||||
targetWidth = Math.round(targetHeight * aspectRatio);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { width: targetWidth, height: targetHeight };
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderToThumbnailDataUrl = ({
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
draw,
|
|
||||||
}: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
draw: ({
|
|
||||||
context,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}: {
|
|
||||||
context: CanvasRenderingContext2D;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}) => void;
|
|
||||||
}): string => {
|
|
||||||
const size = getThumbnailSize({ width, height });
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = size.width;
|
|
||||||
canvas.height = size.height;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("Could not get canvas context");
|
|
||||||
}
|
|
||||||
|
|
||||||
draw({ context, width: size.width, height: size.height });
|
|
||||||
return canvas.toDataURL("image/jpeg", 0.8);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function generateThumbnail({
|
|
||||||
videoFile,
|
|
||||||
timeInSeconds,
|
|
||||||
}: {
|
|
||||||
videoFile: File;
|
|
||||||
timeInSeconds: number;
|
|
||||||
}): Promise<string> {
|
|
||||||
const input = new Input({
|
|
||||||
source: new BlobSource(videoFile),
|
|
||||||
formats: ALL_FORMATS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoTrack = await input.getPrimaryVideoTrack();
|
|
||||||
if (!videoTrack) {
|
|
||||||
throw new Error("No video track found in the file");
|
|
||||||
}
|
|
||||||
|
|
||||||
const canDecode = await videoTrack.canDecode();
|
|
||||||
if (!canDecode) {
|
|
||||||
throw new Error("Video codec not supported for decoding");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sink = new VideoSampleSink(videoTrack);
|
|
||||||
|
|
||||||
const frame = await sink.getSample(timeInSeconds);
|
|
||||||
|
|
||||||
if (!frame) {
|
|
||||||
throw new Error("Could not get frame at specified time");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return renderToThumbnailDataUrl({
|
|
||||||
width: videoTrack.displayWidth,
|
|
||||||
height: videoTrack.displayHeight,
|
|
||||||
draw: ({ context, width, height }) => {
|
|
||||||
frame.draw(context, 0, 0, width, height);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
frame.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateImageThumbnail({
|
async function generateImageThumbnail({
|
||||||
imageFile,
|
imageFile,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -137,7 +50,7 @@ async function generateImageThumbnail({
|
||||||
|
|
||||||
image.addEventListener("load", () => {
|
image.addEventListener("load", () => {
|
||||||
try {
|
try {
|
||||||
const thumbnailUrl = renderToThumbnailDataUrl({
|
const thumbnailUrl = renderThumbnailDataUrl({
|
||||||
width: image.naturalWidth,
|
width: image.naturalWidth,
|
||||||
height: image.naturalHeight,
|
height: image.naturalHeight,
|
||||||
draw: ({ context, width, height }) => {
|
draw: ({ context, width, height }) => {
|
||||||
|
|
@ -220,24 +133,34 @@ export async function processMediaAssets({
|
||||||
height = result.height;
|
height = result.height;
|
||||||
} else if (fileType === "video") {
|
} else if (fileType === "video") {
|
||||||
try {
|
try {
|
||||||
const videoInfo = await getVideoInfo({ videoFile: file });
|
const videoData = await readVideoFile({ file });
|
||||||
duration = videoInfo.duration;
|
duration = videoData.duration;
|
||||||
width = videoInfo.width;
|
width = videoData.width;
|
||||||
height = videoInfo.height;
|
height = videoData.height;
|
||||||
fps = Number.isFinite(videoInfo.fps)
|
fps = Number.isFinite(videoData.fps)
|
||||||
? Math.round(videoInfo.fps)
|
? Math.round(videoData.fps)
|
||||||
: undefined;
|
: undefined;
|
||||||
hasAudio = videoInfo.hasAudio;
|
hasAudio = videoData.hasAudio;
|
||||||
|
thumbnailUrl = videoData.thumbnailUrl ?? undefined;
|
||||||
|
|
||||||
thumbnailUrl = await generateThumbnail({
|
if (!videoData.canDecode) {
|
||||||
videoFile: file,
|
toast.error(`Can't preview ${file.name}`, {
|
||||||
timeInSeconds: 1,
|
description: getUnsupportedVideoDescription({
|
||||||
});
|
codec: videoData.codec,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Video processing failed", error);
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not process video";
|
||||||
|
|
||||||
|
toast.error(`Couldn't process ${file.name}`, {
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else if (fileType === "audio") {
|
} else if (fileType === "audio") {
|
||||||
// For audio, we don't set width/height/fps (they'll be undefined)
|
|
||||||
duration = await getMediaDuration({ file });
|
duration = await getMediaDuration({ file });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,7 +187,7 @@ export async function processMediaAssets({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing file:", file.name, error);
|
console.error("Error processing file:", file.name, error);
|
||||||
toast.error(`Failed to process ${file.name}`);
|
toast.error(`Failed to process ${file.name}`);
|
||||||
URL.revokeObjectURL(url); // Clean up on error
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
const THUMBNAIL_MAX_WIDTH = 1280;
|
||||||
|
const THUMBNAIL_MAX_HEIGHT = 720;
|
||||||
|
|
||||||
|
export function thumbnailSize({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}): { width: number; height: number } {
|
||||||
|
const aspectRatio = width / height;
|
||||||
|
let targetWidth = width;
|
||||||
|
let targetHeight = height;
|
||||||
|
|
||||||
|
if (targetWidth > THUMBNAIL_MAX_WIDTH) {
|
||||||
|
targetWidth = THUMBNAIL_MAX_WIDTH;
|
||||||
|
targetHeight = Math.round(targetWidth / aspectRatio);
|
||||||
|
}
|
||||||
|
if (targetHeight > THUMBNAIL_MAX_HEIGHT) {
|
||||||
|
targetHeight = THUMBNAIL_MAX_HEIGHT;
|
||||||
|
targetWidth = Math.round(targetHeight * aspectRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: targetWidth, height: targetHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderThumbnailDataUrl({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
draw,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
draw: ({
|
||||||
|
context,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
context: CanvasRenderingContext2D;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) => void;
|
||||||
|
}): string {
|
||||||
|
const size = thumbnailSize({ width, height });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size.width;
|
||||||
|
canvas.height = size.height;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Could not get canvas context");
|
||||||
|
}
|
||||||
|
|
||||||
|
draw({ context, width: size.width, height: size.height });
|
||||||
|
return canvas.toDataURL("image/jpeg", 0.8);
|
||||||
|
}
|
||||||
|
|
@ -132,7 +132,7 @@ function PreviewCanvas({
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasMountRef = useRef<HTMLDivElement>(null);
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const lastFrameRef = useRef(-1);
|
const lastFrameRef = useRef(-1);
|
||||||
const lastSceneRef = useRef<RootNode | null>(null);
|
const lastSceneRef = useRef<RootNode | null>(null);
|
||||||
|
|
@ -158,35 +158,51 @@ function PreviewCanvas({
|
||||||
});
|
});
|
||||||
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
|
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
|
||||||
|
|
||||||
const render = useCallback(() => {
|
// Mount the compositor's output canvas directly into the preview. wgpu
|
||||||
if (canvasRef.current && renderTree && !renderingRef.current) {
|
// renders straight into this element, so there is no intermediate copy —
|
||||||
const renderTime = Math.min(
|
// the container div owns positioning/styling, the canvas itself fills it.
|
||||||
editor.playback.getCurrentTime(),
|
useEffect(() => {
|
||||||
editor.timeline.getLastFrameTime(),
|
const mount = canvasMountRef.current;
|
||||||
);
|
if (!mount) return;
|
||||||
const ticksPerFrame = Math.round(
|
const outputCanvas = renderer.getOutputCanvas();
|
||||||
(TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator,
|
outputCanvas.style.display = "block";
|
||||||
);
|
outputCanvas.style.width = "100%";
|
||||||
const frame = Math.floor(renderTime / ticksPerFrame);
|
outputCanvas.style.height = "100%";
|
||||||
|
mount.appendChild(outputCanvas);
|
||||||
if (
|
return () => {
|
||||||
frame !== lastFrameRef.current ||
|
if (outputCanvas.parentElement === mount) {
|
||||||
renderTree !== lastSceneRef.current
|
mount.removeChild(outputCanvas);
|
||||||
) {
|
|
||||||
renderingRef.current = true;
|
|
||||||
lastSceneRef.current = renderTree;
|
|
||||||
lastFrameRef.current = frame;
|
|
||||||
renderer
|
|
||||||
.renderToCanvas({
|
|
||||||
node: renderTree,
|
|
||||||
time: renderTime,
|
|
||||||
targetCanvas: canvasRef.current,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
renderingRef.current = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}, [renderer]);
|
||||||
|
|
||||||
|
const render = useCallback(() => {
|
||||||
|
if (!renderTree || renderingRef.current) return;
|
||||||
|
|
||||||
|
const renderTime = Math.min(
|
||||||
|
editor.playback.getCurrentTime(),
|
||||||
|
editor.timeline.getLastFrameTime(),
|
||||||
|
);
|
||||||
|
const ticksPerFrame = Math.round(
|
||||||
|
(TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator,
|
||||||
|
);
|
||||||
|
const frame = Math.floor(renderTime / ticksPerFrame);
|
||||||
|
|
||||||
|
if (
|
||||||
|
frame === lastFrameRef.current &&
|
||||||
|
renderTree === lastSceneRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderingRef.current = true;
|
||||||
|
lastSceneRef.current = renderTree;
|
||||||
|
lastFrameRef.current = frame;
|
||||||
|
renderer
|
||||||
|
.render({ node: renderTree, time: renderTime })
|
||||||
|
.then(() => {
|
||||||
|
renderingRef.current = false;
|
||||||
|
});
|
||||||
}, [renderer, renderTree, editor.playback, editor.timeline]);
|
}, [renderer, renderTree, editor.playback, editor.timeline]);
|
||||||
|
|
||||||
useRafLoop(render);
|
useRafLoop(render);
|
||||||
|
|
@ -286,22 +302,20 @@ function PreviewCanvas({
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
|
className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
|
||||||
>
|
>
|
||||||
<canvas
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasMountRef}
|
||||||
width={nativeWidth}
|
className="absolute block border"
|
||||||
height={nativeHeight}
|
style={{
|
||||||
className="absolute block border"
|
left: viewport.sceneLeft,
|
||||||
style={{
|
top: viewport.sceneTop,
|
||||||
left: viewport.sceneLeft,
|
width: viewport.sceneWidth,
|
||||||
top: viewport.sceneTop,
|
height: viewport.sceneHeight,
|
||||||
width: viewport.sceneWidth,
|
background:
|
||||||
height: viewport.sceneHeight,
|
activeProject.settings.background.type === "blur"
|
||||||
background:
|
? "transparent"
|
||||||
activeProject.settings.background.type === "blur"
|
: activeProject?.settings.background.color,
|
||||||
? "transparent"
|
}}
|
||||||
: activeProject?.settings.background.color,
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PreviewOverlayLayer
|
<PreviewOverlayLayer
|
||||||
instances={overlayInstances}
|
instances={overlayInstances}
|
||||||
plane="under-interaction"
|
plane="under-interaction"
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import type { TextElement } from "@/timeline";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveTransformAtTime,
|
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
import { resolveTextLayout } from "@/text/primitives";
|
import { resolveTextLayout } from "@/text/primitives";
|
||||||
|
|
||||||
export function TextEditOverlay({
|
export function TextEditOverlay({
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,10 @@ import { EditableTimecode } from "@/components/editable-timecode";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
FullScreenIcon,
|
FullScreenIcon,
|
||||||
GridTableIcon,
|
|
||||||
PauseIcon,
|
PauseIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { getGuideById } from "@/guides";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -33,9 +31,6 @@ export function PreviewToolbar({
|
||||||
}: {
|
}: {
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
}) {
|
}) {
|
||||||
const activeGuide = usePreviewStore((state) => state.activeGuide);
|
|
||||||
const activeGuideDefinition = getGuideById(activeGuide);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
||||||
<TimecodeDisplay />
|
<TimecodeDisplay />
|
||||||
|
|
@ -93,7 +88,11 @@ function TimecodeDisplay() {
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
||||||
<span className="text-muted-foreground font-mono text-xs">
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
{formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
|
{formatTimecode({
|
||||||
|
time: totalDuration,
|
||||||
|
format: "HH:MM:SS:FF",
|
||||||
|
rate: fps,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ import { isVisualElement } from "@/timeline/element-utils";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
resolveTransformAtTime,
|
|
||||||
setChannel,
|
setChannel,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import type { ElementAnimations } from "@/animation/types";
|
import type { ElementAnimations } from "@/animation/types";
|
||||||
import type { Transform } from "@/rendering";
|
import type { Transform } from "@/rendering";
|
||||||
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
import type {
|
import type {
|
||||||
ElementRef,
|
ElementRef,
|
||||||
SceneTracks,
|
SceneTracks,
|
||||||
|
|
|
||||||
|
|
@ -1,308 +1,308 @@
|
||||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||||
import type { MediaAsset } from "@/media/types";
|
import type { MediaAsset } from "@/media/types";
|
||||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
|
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
|
||||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||||
import { measureTextElement } from "@/text/measure-element";
|
import { measureTextElement } from "@/text/measure-element";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveTransformAtTime,
|
} from "@/animation";
|
||||||
} from "@/animation";
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
|
|
||||||
export interface ElementBounds {
|
export interface ElementBounds {
|
||||||
cx: number;
|
cx: number;
|
||||||
cy: number;
|
cy: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ElementWithBounds {
|
export interface ElementWithBounds {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
bounds: ElementBounds;
|
bounds: ElementBounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisualElementBounds({
|
function getVisualElementBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
sourceWidth,
|
sourceWidth,
|
||||||
sourceHeight,
|
sourceHeight,
|
||||||
transform,
|
transform,
|
||||||
}: {
|
}: {
|
||||||
canvasWidth: number;
|
canvasWidth: number;
|
||||||
canvasHeight: number;
|
canvasHeight: number;
|
||||||
sourceWidth: number;
|
sourceWidth: number;
|
||||||
sourceHeight: number;
|
sourceHeight: number;
|
||||||
transform: {
|
transform: {
|
||||||
scaleX: number;
|
scaleX: number;
|
||||||
scaleY: number;
|
scaleY: number;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
rotate: number;
|
rotate: number;
|
||||||
};
|
};
|
||||||
}): ElementBounds {
|
}): ElementBounds {
|
||||||
const containScale = Math.min(
|
const containScale = Math.min(
|
||||||
canvasWidth / sourceWidth,
|
canvasWidth / sourceWidth,
|
||||||
canvasHeight / sourceHeight,
|
canvasHeight / sourceHeight,
|
||||||
);
|
);
|
||||||
const scaledWidth = sourceWidth * containScale * transform.scaleX;
|
const scaledWidth = sourceWidth * containScale * transform.scaleX;
|
||||||
const scaledHeight = sourceHeight * containScale * transform.scaleY;
|
const scaledHeight = sourceHeight * containScale * transform.scaleY;
|
||||||
const cx = canvasWidth / 2 + transform.position.x;
|
const cx = canvasWidth / 2 + transform.position.x;
|
||||||
const cy = canvasHeight / 2 + transform.position.y;
|
const cy = canvasHeight / 2 + transform.position.y;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cx,
|
cx,
|
||||||
cy,
|
cy,
|
||||||
width: scaledWidth,
|
width: scaledWidth,
|
||||||
height: scaledHeight,
|
height: scaledHeight,
|
||||||
rotation: transform.rotate,
|
rotation: transform.rotate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransformedRectBounds({
|
function getTransformedRectBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
rect,
|
rect,
|
||||||
transform,
|
transform,
|
||||||
}: {
|
}: {
|
||||||
canvasWidth: number;
|
canvasWidth: number;
|
||||||
canvasHeight: number;
|
canvasHeight: number;
|
||||||
rect: { left: number; top: number; width: number; height: number };
|
rect: { left: number; top: number; width: number; height: number };
|
||||||
transform: {
|
transform: {
|
||||||
scaleX: number;
|
scaleX: number;
|
||||||
scaleY: number;
|
scaleY: number;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
rotate: number;
|
rotate: number;
|
||||||
};
|
};
|
||||||
}): ElementBounds {
|
}): ElementBounds {
|
||||||
const localCenterX = rect.left + rect.width / 2;
|
const localCenterX = rect.left + rect.width / 2;
|
||||||
const localCenterY = rect.top + rect.height / 2;
|
const localCenterY = rect.top + rect.height / 2;
|
||||||
const scaledCenterX = localCenterX * transform.scaleX;
|
const scaledCenterX = localCenterX * transform.scaleX;
|
||||||
const scaledCenterY = localCenterY * transform.scaleY;
|
const scaledCenterY = localCenterY * transform.scaleY;
|
||||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||||
const cos = Math.cos(rotationRad);
|
const cos = Math.cos(rotationRad);
|
||||||
const sin = Math.sin(rotationRad);
|
const sin = Math.sin(rotationRad);
|
||||||
return {
|
return {
|
||||||
cx:
|
cx:
|
||||||
canvasWidth / 2 +
|
canvasWidth / 2 +
|
||||||
transform.position.x +
|
transform.position.x +
|
||||||
scaledCenterX * cos -
|
scaledCenterX * cos -
|
||||||
scaledCenterY * sin,
|
scaledCenterY * sin,
|
||||||
cy:
|
cy:
|
||||||
canvasHeight / 2 +
|
canvasHeight / 2 +
|
||||||
transform.position.y +
|
transform.position.y +
|
||||||
scaledCenterX * sin +
|
scaledCenterX * sin +
|
||||||
scaledCenterY * cos,
|
scaledCenterY * cos,
|
||||||
width: rect.width * transform.scaleX,
|
width: rect.width * transform.scaleX,
|
||||||
height: rect.height * transform.scaleY,
|
height: rect.height * transform.scaleY,
|
||||||
rotation: transform.rotate,
|
rotation: transform.rotate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
|
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
|
||||||
* sticker/image/video content area) and base transform. Post-effect spill (blur,
|
* sticker/image/video content area) and base transform. Post-effect spill (blur,
|
||||||
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
|
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
|
||||||
* the canonical element geometry, not visual effect output.
|
* the canonical element geometry, not visual effect output.
|
||||||
*/
|
*/
|
||||||
function getElementBounds({
|
function getElementBounds({
|
||||||
element,
|
element,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
mediaAsset,
|
mediaAsset,
|
||||||
localTime,
|
localTime,
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
canvasSize: { width: number; height: number };
|
canvasSize: { width: number; height: number };
|
||||||
mediaAsset?: MediaAsset | null;
|
mediaAsset?: MediaAsset | null;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): ElementBounds | null {
|
}): ElementBounds | null {
|
||||||
if (element.type === "audio" || element.type === "effect") return null;
|
if (element.type === "audio" || element.type === "effect") return null;
|
||||||
if ("hidden" in element && element.hidden) return null;
|
if ("hidden" in element && element.hidden) return null;
|
||||||
|
|
||||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||||
|
|
||||||
if (element.type === "video" || element.type === "image") {
|
if (element.type === "video" || element.type === "image") {
|
||||||
const transform = resolveTransformAtTime({
|
const transform = resolveTransformAtTime({
|
||||||
baseTransform: element.transform,
|
baseTransform: element.transform,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
||||||
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
||||||
return getVisualElementBounds({
|
return getVisualElementBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
sourceWidth,
|
sourceWidth,
|
||||||
sourceHeight,
|
sourceHeight,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.type === "sticker") {
|
if (element.type === "sticker") {
|
||||||
const transform = resolveTransformAtTime({
|
const transform = resolveTransformAtTime({
|
||||||
baseTransform: element.transform,
|
baseTransform: element.transform,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
return getVisualElementBounds({
|
return getVisualElementBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||||
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.type === "graphic") {
|
if (element.type === "graphic") {
|
||||||
const transform = resolveTransformAtTime({
|
const transform = resolveTransformAtTime({
|
||||||
baseTransform: element.transform,
|
baseTransform: element.transform,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
return getVisualElementBounds({
|
return getVisualElementBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.type === "text") {
|
if (element.type === "text") {
|
||||||
const transform = resolveTransformAtTime({
|
const transform = resolveTransformAtTime({
|
||||||
baseTransform: element.transform,
|
baseTransform: element.transform,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
|
|
||||||
const measured = measureTextElement({
|
const measured = measureTextElement({
|
||||||
element,
|
element,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
localTime,
|
localTime,
|
||||||
ctx,
|
ctx,
|
||||||
});
|
});
|
||||||
|
|
||||||
return getTransformedRectBounds({
|
return getTransformedRectBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
rect: measured.visualRect,
|
rect: measured.visualRect,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROTATION_HANDLE_OFFSET = 24;
|
export const ROTATION_HANDLE_OFFSET = 24;
|
||||||
|
|
||||||
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||||
export type Edge = "right" | "left" | "bottom";
|
export type Edge = "right" | "left" | "bottom";
|
||||||
|
|
||||||
export function getCornerPosition({
|
export function getCornerPosition({
|
||||||
bounds,
|
bounds,
|
||||||
corner,
|
corner,
|
||||||
}: {
|
}: {
|
||||||
bounds: ElementBounds;
|
bounds: ElementBounds;
|
||||||
corner: Corner;
|
corner: Corner;
|
||||||
}): { x: number; y: number } {
|
}): { x: number; y: number } {
|
||||||
const halfW = bounds.width / 2;
|
const halfW = bounds.width / 2;
|
||||||
const halfH = bounds.height / 2;
|
const halfH = bounds.height / 2;
|
||||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||||
const cos = Math.cos(angleRad);
|
const cos = Math.cos(angleRad);
|
||||||
const sin = Math.sin(angleRad);
|
const sin = Math.sin(angleRad);
|
||||||
const localX =
|
const localX =
|
||||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||||
const localY =
|
const localY =
|
||||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||||
return {
|
return {
|
||||||
x: bounds.cx + (localX * cos - localY * sin),
|
x: bounds.cx + (localX * cos - localY * sin),
|
||||||
y: bounds.cy + (localX * sin + localY * cos),
|
y: bounds.cy + (localX * sin + localY * cos),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEdgeHandlePosition({
|
export function getEdgeHandlePosition({
|
||||||
bounds,
|
bounds,
|
||||||
edge,
|
edge,
|
||||||
}: {
|
}: {
|
||||||
bounds: ElementBounds;
|
bounds: ElementBounds;
|
||||||
edge: Edge;
|
edge: Edge;
|
||||||
}): { x: number; y: number } {
|
}): { x: number; y: number } {
|
||||||
const halfWidth = bounds.width / 2;
|
const halfWidth = bounds.width / 2;
|
||||||
const halfHeight = bounds.height / 2;
|
const halfHeight = bounds.height / 2;
|
||||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||||
const cos = Math.cos(angleRad);
|
const cos = Math.cos(angleRad);
|
||||||
const sin = Math.sin(angleRad);
|
const sin = Math.sin(angleRad);
|
||||||
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
|
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
|
||||||
const localY = edge === "bottom" ? halfHeight : 0;
|
const localY = edge === "bottom" ? halfHeight : 0;
|
||||||
return {
|
return {
|
||||||
x: bounds.cx + (localX * cos - localY * sin),
|
x: bounds.cx + (localX * cos - localY * sin),
|
||||||
y: bounds.cy + (localX * sin + localY * cos),
|
y: bounds.cy + (localX * sin + localY * cos),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVisibleElementsWithBounds({
|
export function getVisibleElementsWithBounds({
|
||||||
tracks,
|
tracks,
|
||||||
currentTime,
|
currentTime,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
}: {
|
}: {
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
canvasSize: { width: number; height: number };
|
canvasSize: { width: number; height: number };
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
}): ElementWithBounds[] {
|
}): ElementWithBounds[] {
|
||||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||||
const orderedTracks = [
|
const orderedTracks = [
|
||||||
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
||||||
...(!tracks.main.hidden ? [tracks.main] : []),
|
...(!tracks.main.hidden ? [tracks.main] : []),
|
||||||
].reverse();
|
].reverse();
|
||||||
|
|
||||||
const result: ElementWithBounds[] = [];
|
const result: ElementWithBounds[] = [];
|
||||||
|
|
||||||
for (const track of orderedTracks) {
|
for (const track of orderedTracks) {
|
||||||
const elements = track.elements
|
const elements = track.elements
|
||||||
.filter((element) => !("hidden" in element && element.hidden))
|
.filter((element) => !("hidden" in element && element.hidden))
|
||||||
.filter(
|
.filter(
|
||||||
(element) =>
|
(element) =>
|
||||||
currentTime >= element.startTime &&
|
currentTime >= element.startTime &&
|
||||||
currentTime < element.startTime + element.duration,
|
currentTime < element.startTime + element.duration,
|
||||||
)
|
)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
||||||
return a.id.localeCompare(b.id);
|
return a.id.localeCompare(b.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
const localTime = getElementLocalTime({
|
const localTime = getElementLocalTime({
|
||||||
timelineTime: currentTime,
|
timelineTime: currentTime,
|
||||||
elementStartTime: element.startTime,
|
elementStartTime: element.startTime,
|
||||||
elementDuration: element.duration,
|
elementDuration: element.duration,
|
||||||
});
|
});
|
||||||
const mediaAsset =
|
const mediaAsset =
|
||||||
element.type === "video" || element.type === "image"
|
element.type === "video" || element.type === "image"
|
||||||
? mediaMap.get(element.mediaId)
|
? mediaMap.get(element.mediaId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const bounds = getElementBounds({
|
const bounds = getElementBounds({
|
||||||
element,
|
element,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
mediaAsset,
|
mediaAsset,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
result.push({
|
result.push({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
element,
|
element,
|
||||||
bounds,
|
bounds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +27,7 @@ import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
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 { DEFAULTS } from "@/timeline/defaults";
|
||||||
import { isPropertyAtDefault } from "./transform-tab";
|
import { isPropertyAtDefault } from "./transform-tab";
|
||||||
import type { MediaTime } from "@/wasm";
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ import {
|
||||||
import {
|
import {
|
||||||
getGroupKeyframesAtTime,
|
getGroupKeyframesAtTime,
|
||||||
hasGroupKeyframeAtTime,
|
hasGroupKeyframeAtTime,
|
||||||
resolveTransformAtTime,
|
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ import type { AnyBaseNode } from "./nodes/base-node";
|
||||||
import { buildFrameDescriptor } from "./compositor/frame-descriptor";
|
import { buildFrameDescriptor } from "./compositor/frame-descriptor";
|
||||||
import { wasmCompositor } from "./compositor/wasm-compositor";
|
import { wasmCompositor } from "./compositor/wasm-compositor";
|
||||||
import { resolveRenderTree } from "./resolve";
|
import { resolveRenderTree } from "./resolve";
|
||||||
|
import {
|
||||||
|
measureSpanAsync,
|
||||||
|
measureSpanSync,
|
||||||
|
onRenderPerfFrameComplete,
|
||||||
|
} from "@/diagnostics/render-perf";
|
||||||
|
|
||||||
export type CanvasRendererParams = {
|
export type CanvasRendererParams = {
|
||||||
width: number;
|
width: number;
|
||||||
|
|
@ -69,17 +74,26 @@ export class CanvasRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async render({ node, time }: { node: AnyBaseNode; time: number }) {
|
async render({ node, time }: { node: AnyBaseNode; time: number }) {
|
||||||
await resolveRenderTree({ node, renderer: this, time });
|
await measureSpanAsync({
|
||||||
const { frame, textures } = await buildFrameDescriptor({
|
name: "resolve",
|
||||||
node,
|
fn: () => resolveRenderTree({ node, renderer: this, time }),
|
||||||
renderer: this,
|
});
|
||||||
|
const { frame, textures } = await measureSpanAsync({
|
||||||
|
name: "buildFrame",
|
||||||
|
fn: () => buildFrameDescriptor({ node, renderer: this }),
|
||||||
});
|
});
|
||||||
wasmCompositor.ensureInitialized({
|
wasmCompositor.ensureInitialized({
|
||||||
width: this.width,
|
width: this.width,
|
||||||
height: this.height,
|
height: this.height,
|
||||||
});
|
});
|
||||||
wasmCompositor.syncTextures(textures);
|
measureSpanSync({
|
||||||
wasmCompositor.render(frame);
|
name: "syncTextures",
|
||||||
|
fn: () => wasmCompositor.syncTextures(textures),
|
||||||
|
});
|
||||||
|
measureSpanSync({
|
||||||
|
name: "renderFrame",
|
||||||
|
fn: () => wasmCompositor.render(frame),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderToCanvas({
|
async renderToCanvas({
|
||||||
|
|
@ -98,12 +112,17 @@ export class CanvasRenderer {
|
||||||
throw new Error("Failed to get target canvas context");
|
throw new Error("Failed to get target canvas context");
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.drawImage(
|
measureSpanSync({
|
||||||
wasmCompositor.getCanvas(),
|
name: "drawImage",
|
||||||
0,
|
fn: () =>
|
||||||
0,
|
ctx.drawImage(
|
||||||
targetCanvas.width,
|
wasmCompositor.getCanvas(),
|
||||||
targetCanvas.height,
|
0,
|
||||||
);
|
0,
|
||||||
|
targetCanvas.width,
|
||||||
|
targetCanvas.height,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
onRenderPerfFrameComplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { drawCssBackground } from "@/gradients";
|
import { drawCssBackground } from "@/gradients";
|
||||||
import { masksRegistry } from "@/masks";
|
import { masksRegistry } from "@/masks";
|
||||||
|
import { incrementCounter } from "@/diagnostics/render-perf";
|
||||||
import type { AnyBaseNode } from "../nodes/base-node";
|
import type { AnyBaseNode } from "../nodes/base-node";
|
||||||
import type { CanvasRenderer } from "../canvas-renderer";
|
import type { CanvasRenderer } from "../canvas-renderer";
|
||||||
import { createOffscreenCanvas } from "../canvas-utils";
|
import { createOffscreenCanvas } from "../canvas-utils";
|
||||||
|
|
@ -21,16 +22,11 @@ import type {
|
||||||
FrameItemDescriptor,
|
FrameItemDescriptor,
|
||||||
LayerMaskDescriptor,
|
LayerMaskDescriptor,
|
||||||
QuadTransformDescriptor,
|
QuadTransformDescriptor,
|
||||||
|
TextureCanvasDrawFn,
|
||||||
|
TextureUploadDescriptor,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||||
|
|
||||||
export type TextureUploadDescriptor = {
|
|
||||||
id: string;
|
|
||||||
source: CanvasImageSource;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function buildFrameDescriptor({
|
export async function buildFrameDescriptor({
|
||||||
node,
|
node,
|
||||||
renderer,
|
renderer,
|
||||||
|
|
@ -52,6 +48,9 @@ export async function buildFrameDescriptor({
|
||||||
textures,
|
textures,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
incrementCounter({ name: "frameItems", by: items.length });
|
||||||
|
incrementCounter({ name: "frameTextures", by: textures.size });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
frame: {
|
frame: {
|
||||||
width: renderer.width,
|
width: renderer.width,
|
||||||
|
|
@ -93,31 +92,21 @@ async function collectNode({
|
||||||
|
|
||||||
if (node instanceof ColorNode) {
|
if (node instanceof ColorNode) {
|
||||||
const textureId = `${path}:color`;
|
const textureId = `${path}:color`;
|
||||||
const canvas = createOffscreenCanvas({
|
const { width, height } = renderer;
|
||||||
width: renderer.width,
|
|
||||||
height: renderer.height,
|
|
||||||
});
|
|
||||||
const ctx = canvas.getContext("2d") as
|
|
||||||
| CanvasRenderingContext2D
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| null;
|
|
||||||
if (!ctx) return;
|
|
||||||
if (/gradient\(/i.test(node.params.color)) {
|
|
||||||
drawCssBackground({
|
|
||||||
ctx,
|
|
||||||
width: renderer.width,
|
|
||||||
height: renderer.height,
|
|
||||||
css: node.params.color,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle = node.params.color;
|
|
||||||
ctx.fillRect(0, 0, renderer.width, renderer.height);
|
|
||||||
}
|
|
||||||
textures.set(textureId, {
|
textures.set(textureId, {
|
||||||
|
kind: "rendered",
|
||||||
id: textureId,
|
id: textureId,
|
||||||
source: canvas,
|
contentHash: `color:${node.params.color}:${width}x${height}`,
|
||||||
width: renderer.width,
|
width,
|
||||||
height: renderer.height,
|
height,
|
||||||
|
draw: (ctx) => {
|
||||||
|
if (/gradient\(/i.test(node.params.color)) {
|
||||||
|
drawCssBackground({ ctx, width, height, css: node.params.color });
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = node.params.color;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
type: "layer",
|
type: "layer",
|
||||||
|
|
@ -147,36 +136,35 @@ async function collectNode({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const textureId = `${path}:blur-background`;
|
const textureId = `${path}:blur-background`;
|
||||||
const backdropCanvas = createOffscreenCanvas({
|
const { width, height } = renderer;
|
||||||
width: renderer.width,
|
|
||||||
height: renderer.height,
|
|
||||||
});
|
|
||||||
const backdropCtx = backdropCanvas.getContext("2d") as
|
|
||||||
| CanvasRenderingContext2D
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| null;
|
|
||||||
if (!backdropCtx) return;
|
|
||||||
const { backdropSource, passes } = node.resolved;
|
const { backdropSource, passes } = node.resolved;
|
||||||
const coverScale = Math.max(
|
// Backdrop pixels come from a decoded video/image frame whose identity
|
||||||
renderer.width / backdropSource.width,
|
// already changes when it changes. Hashing the source reference is
|
||||||
renderer.height / backdropSource.height,
|
// enough to let us skip redraws on frozen frames.
|
||||||
);
|
const contentHash = `blur:${identityKey(backdropSource.source)}:${backdropSource.width}x${backdropSource.height}:${width}x${height}`;
|
||||||
const scaledWidth = backdropSource.width * coverScale;
|
|
||||||
const scaledHeight = backdropSource.height * coverScale;
|
|
||||||
const offsetX = (renderer.width - scaledWidth) / 2;
|
|
||||||
const offsetY = (renderer.height - scaledHeight) / 2;
|
|
||||||
backdropCtx.drawImage(
|
|
||||||
backdropSource.source,
|
|
||||||
offsetX,
|
|
||||||
offsetY,
|
|
||||||
scaledWidth,
|
|
||||||
scaledHeight,
|
|
||||||
);
|
|
||||||
textures.set(textureId, {
|
textures.set(textureId, {
|
||||||
|
kind: "rendered",
|
||||||
id: textureId,
|
id: textureId,
|
||||||
source: backdropCanvas,
|
contentHash,
|
||||||
width: renderer.width,
|
width,
|
||||||
height: renderer.height,
|
height,
|
||||||
|
draw: (ctx) => {
|
||||||
|
const coverScale = Math.max(
|
||||||
|
width / backdropSource.width,
|
||||||
|
height / backdropSource.height,
|
||||||
|
);
|
||||||
|
const scaledWidth = backdropSource.width * coverScale;
|
||||||
|
const scaledHeight = backdropSource.height * coverScale;
|
||||||
|
const offsetX = (width - scaledWidth) / 2;
|
||||||
|
const offsetY = (height - scaledHeight) / 2;
|
||||||
|
ctx.drawImage(
|
||||||
|
backdropSource.source,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
scaledWidth,
|
||||||
|
scaledHeight,
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
type: "layer",
|
type: "layer",
|
||||||
|
|
@ -253,6 +241,7 @@ async function collectVisualSourceNode({
|
||||||
|
|
||||||
const textureId = `${path}:source`;
|
const textureId = `${path}:source`;
|
||||||
textures.set(textureId, {
|
textures.set(textureId, {
|
||||||
|
kind: "external",
|
||||||
id: textureId,
|
id: textureId,
|
||||||
source,
|
source,
|
||||||
width: sourceWidth,
|
width: sourceWidth,
|
||||||
|
|
@ -305,28 +294,24 @@ function collectTextNode({
|
||||||
}
|
}
|
||||||
|
|
||||||
const textureId = `${path}:text`;
|
const textureId = `${path}:text`;
|
||||||
const canvas = createOffscreenCanvas({
|
const { width, height } = renderer;
|
||||||
width: renderer.width,
|
// Text output is fully determined by node.params + node.resolved. Both are
|
||||||
height: renderer.height,
|
// plain data we can stringify cheaply; the resolved measured layout is the
|
||||||
});
|
// expensive part of text setup, so stringifying it here is orders of
|
||||||
const ctx = canvas.getContext("2d") as
|
// magnitude cheaper than re-rasterizing when nothing changed.
|
||||||
| CanvasRenderingContext2D
|
const contentHash = `text:${width}x${height}:${JSON.stringify({
|
||||||
| OffscreenCanvasRenderingContext2D
|
params: node.params,
|
||||||
| null;
|
resolved: node.resolved,
|
||||||
if (!ctx) {
|
})}`;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTextToContext({
|
|
||||||
node,
|
|
||||||
ctx,
|
|
||||||
});
|
|
||||||
|
|
||||||
textures.set(textureId, {
|
textures.set(textureId, {
|
||||||
|
kind: "rendered",
|
||||||
id: textureId,
|
id: textureId,
|
||||||
source: canvas,
|
contentHash,
|
||||||
width: renderer.width,
|
width,
|
||||||
height: renderer.height,
|
height,
|
||||||
|
draw: (ctx) => {
|
||||||
|
renderTextToContext({ node, ctx });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
type: "layer",
|
type: "layer",
|
||||||
|
|
@ -411,20 +396,6 @@ function buildMaskArtifacts({
|
||||||
return { mask: null, strokeLayer: null };
|
return { mask: null, strokeLayer: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const elementMaskCanvas = createOffscreenCanvas({
|
|
||||||
width: Math.round(transform.width),
|
|
||||||
height: Math.round(transform.height),
|
|
||||||
});
|
|
||||||
const elementMaskCtx = elementMaskCanvas.getContext("2d") as
|
|
||||||
| CanvasRenderingContext2D
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| null;
|
|
||||||
if (!elementMaskCtx) {
|
|
||||||
return { mask: null, strokeLayer: null };
|
|
||||||
}
|
|
||||||
elementMaskCtx.clearRect(0, 0, transform.width, transform.height);
|
|
||||||
|
|
||||||
let strokePath: Path2D | null = null;
|
|
||||||
let feather = mask.params.feather;
|
let feather = mask.params.feather;
|
||||||
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
|
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
|
||||||
const shouldRenderMaskDirectly =
|
const shouldRenderMaskDirectly =
|
||||||
|
|
@ -432,81 +403,78 @@ function buildMaskArtifacts({
|
||||||
(!definition.renderer.buildPath ||
|
(!definition.renderer.buildPath ||
|
||||||
(mask.params.feather > 0 &&
|
(mask.params.feather > 0 &&
|
||||||
definition.renderer.renderMaskHandlesFeather));
|
definition.renderer.renderMaskHandlesFeather));
|
||||||
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
|
if (
|
||||||
definition.renderer.renderMask({
|
shouldRenderMaskDirectly &&
|
||||||
resolvedParams: mask.params,
|
definition.renderer.renderMaskHandlesFeather
|
||||||
ctx: elementMaskCtx,
|
) {
|
||||||
width: Math.round(transform.width),
|
feather = 0;
|
||||||
height: Math.round(transform.height),
|
|
||||||
feather: mask.params.feather,
|
|
||||||
});
|
|
||||||
if (definition.renderer.renderMaskHandlesFeather) {
|
|
||||||
feather = 0;
|
|
||||||
}
|
|
||||||
strokePath =
|
|
||||||
definition.renderer.buildStrokePath?.({
|
|
||||||
resolvedParams: mask.params,
|
|
||||||
width: transform.width,
|
|
||||||
height: transform.height,
|
|
||||||
}) ?? null;
|
|
||||||
} else {
|
|
||||||
if (!definition.renderer.buildPath) {
|
|
||||||
return { mask: null, strokeLayer: null };
|
|
||||||
}
|
|
||||||
const path2d = definition.renderer.buildPath({
|
|
||||||
resolvedParams: mask.params,
|
|
||||||
width: transform.width,
|
|
||||||
height: transform.height,
|
|
||||||
});
|
|
||||||
elementMaskCtx.fillStyle = "white";
|
|
||||||
elementMaskCtx.fill(path2d);
|
|
||||||
strokePath =
|
|
||||||
definition.renderer.buildStrokePath?.({
|
|
||||||
resolvedParams: mask.params,
|
|
||||||
width: transform.width,
|
|
||||||
height: transform.height,
|
|
||||||
}) ?? path2d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullMaskCanvas = createOffscreenCanvas({
|
|
||||||
width: renderer.width,
|
|
||||||
height: renderer.height,
|
|
||||||
});
|
|
||||||
const fullMaskCtx = fullMaskCanvas.getContext("2d") as
|
|
||||||
| CanvasRenderingContext2D
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| null;
|
|
||||||
if (!fullMaskCtx) {
|
|
||||||
return { mask: null, strokeLayer: null };
|
|
||||||
}
|
|
||||||
drawTransformedCanvas({
|
|
||||||
ctx: fullMaskCtx,
|
|
||||||
source: elementMaskCanvas,
|
|
||||||
transform,
|
|
||||||
});
|
|
||||||
|
|
||||||
const maskTextureId = `${path}:mask`;
|
const maskTextureId = `${path}:mask`;
|
||||||
textures.set(maskTextureId, {
|
const { width: canvasWidth, height: canvasHeight } = renderer;
|
||||||
id: maskTextureId,
|
const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
|
||||||
source: fullMaskCanvas,
|
const drawMask: TextureCanvasDrawFn = (ctx) => {
|
||||||
width: renderer.width,
|
const elementMaskCanvas = createOffscreenCanvas({
|
||||||
height: renderer.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
let strokeLayer: FrameItemDescriptor | null = null;
|
|
||||||
if (
|
|
||||||
mask.params.strokeWidth > 0 &&
|
|
||||||
(strokePath || definition.renderer.renderStroke)
|
|
||||||
) {
|
|
||||||
const strokeCanvas = createOffscreenCanvas({
|
|
||||||
width: Math.round(transform.width),
|
width: Math.round(transform.width),
|
||||||
height: Math.round(transform.height),
|
height: Math.round(transform.height),
|
||||||
});
|
});
|
||||||
const strokeCtx = strokeCanvas.getContext("2d") as
|
const elementMaskCtx = elementMaskCanvas.getContext("2d") as
|
||||||
| CanvasRenderingContext2D
|
| CanvasRenderingContext2D
|
||||||
| OffscreenCanvasRenderingContext2D
|
| OffscreenCanvasRenderingContext2D
|
||||||
| null;
|
| null;
|
||||||
if (strokeCtx) {
|
if (!elementMaskCtx) return;
|
||||||
|
|
||||||
|
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
|
||||||
|
definition.renderer.renderMask({
|
||||||
|
resolvedParams: mask.params,
|
||||||
|
ctx: elementMaskCtx,
|
||||||
|
width: Math.round(transform.width),
|
||||||
|
height: Math.round(transform.height),
|
||||||
|
feather: mask.params.feather,
|
||||||
|
});
|
||||||
|
} else if (definition.renderer.buildPath) {
|
||||||
|
const path2d = definition.renderer.buildPath({
|
||||||
|
resolvedParams: mask.params,
|
||||||
|
width: transform.width,
|
||||||
|
height: transform.height,
|
||||||
|
});
|
||||||
|
elementMaskCtx.fillStyle = "white";
|
||||||
|
elementMaskCtx.fill(path2d);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform });
|
||||||
|
};
|
||||||
|
textures.set(maskTextureId, {
|
||||||
|
kind: "rendered",
|
||||||
|
id: maskTextureId,
|
||||||
|
contentHash: maskContentHash,
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight,
|
||||||
|
draw: drawMask,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasStroke =
|
||||||
|
mask.params.strokeWidth > 0 &&
|
||||||
|
(definition.renderer.renderStroke ||
|
||||||
|
definition.renderer.buildStrokePath ||
|
||||||
|
definition.renderer.buildPath);
|
||||||
|
let strokeLayer: FrameItemDescriptor | null = null;
|
||||||
|
if (hasStroke) {
|
||||||
|
const strokeTextureId = `${path}:mask-stroke`;
|
||||||
|
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`;
|
||||||
|
const drawStroke: TextureCanvasDrawFn = (ctx) => {
|
||||||
|
const strokeCanvas = createOffscreenCanvas({
|
||||||
|
width: Math.round(transform.width),
|
||||||
|
height: Math.round(transform.height),
|
||||||
|
});
|
||||||
|
const strokeCtx = strokeCanvas.getContext("2d") as
|
||||||
|
| CanvasRenderingContext2D
|
||||||
|
| OffscreenCanvasRenderingContext2D
|
||||||
|
| null;
|
||||||
|
if (!strokeCtx) return;
|
||||||
|
|
||||||
if (definition.renderer.renderStroke) {
|
if (definition.renderer.renderStroke) {
|
||||||
definition.renderer.renderStroke({
|
definition.renderer.renderStroke({
|
||||||
resolvedParams: mask.params,
|
resolvedParams: mask.params,
|
||||||
|
|
@ -514,44 +482,44 @@ function buildMaskArtifacts({
|
||||||
width: transform.width,
|
width: transform.width,
|
||||||
height: transform.height,
|
height: transform.height,
|
||||||
});
|
});
|
||||||
} else if (strokePath) {
|
} else {
|
||||||
|
const strokePath =
|
||||||
|
definition.renderer.buildStrokePath?.({
|
||||||
|
resolvedParams: mask.params,
|
||||||
|
width: transform.width,
|
||||||
|
height: transform.height,
|
||||||
|
}) ??
|
||||||
|
definition.renderer.buildPath?.({
|
||||||
|
resolvedParams: mask.params,
|
||||||
|
width: transform.width,
|
||||||
|
height: transform.height,
|
||||||
|
}) ??
|
||||||
|
null;
|
||||||
|
if (!strokePath) return;
|
||||||
strokeCtx.strokeStyle = mask.params.strokeColor;
|
strokeCtx.strokeStyle = mask.params.strokeColor;
|
||||||
strokeCtx.lineWidth = mask.params.strokeWidth;
|
strokeCtx.lineWidth = mask.params.strokeWidth;
|
||||||
strokeCtx.stroke(strokePath);
|
strokeCtx.stroke(strokePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullStrokeCanvas = createOffscreenCanvas({
|
drawTransformedCanvas({ ctx, source: strokeCanvas, transform });
|
||||||
width: renderer.width,
|
};
|
||||||
height: renderer.height,
|
textures.set(strokeTextureId, {
|
||||||
});
|
kind: "rendered",
|
||||||
const fullStrokeCtx = fullStrokeCanvas.getContext("2d") as
|
id: strokeTextureId,
|
||||||
| CanvasRenderingContext2D
|
contentHash: strokeContentHash,
|
||||||
| OffscreenCanvasRenderingContext2D
|
width: canvasWidth,
|
||||||
| null;
|
height: canvasHeight,
|
||||||
if (fullStrokeCtx) {
|
draw: drawStroke,
|
||||||
drawTransformedCanvas({
|
});
|
||||||
ctx: fullStrokeCtx,
|
strokeLayer = {
|
||||||
source: strokeCanvas,
|
type: "layer",
|
||||||
transform,
|
textureId: strokeTextureId,
|
||||||
});
|
transform: fullCanvasTransform(renderer),
|
||||||
const strokeTextureId = `${path}:mask-stroke`;
|
opacity: 1,
|
||||||
textures.set(strokeTextureId, {
|
blendMode: "normal",
|
||||||
id: strokeTextureId,
|
effectPassGroups: [],
|
||||||
source: fullStrokeCanvas,
|
mask: null,
|
||||||
width: renderer.width,
|
};
|
||||||
height: renderer.height,
|
|
||||||
});
|
|
||||||
strokeLayer = {
|
|
||||||
type: "layer",
|
|
||||||
textureId: strokeTextureId,
|
|
||||||
transform: fullCanvasTransform(renderer),
|
|
||||||
opacity: 1,
|
|
||||||
blendMode: "normal",
|
|
||||||
effectPassGroups: [],
|
|
||||||
mask: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -590,3 +558,23 @@ function drawTransformedCanvas({
|
||||||
ctx.drawImage(source, x, y, transform.width, transform.height);
|
ctx.drawImage(source, x, y, transform.width, transform.height);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transformHash(transform: QuadTransformDescriptor): string {
|
||||||
|
return `${transform.centerX}:${transform.centerY}:${transform.width}:${transform.height}:${transform.rotationDegrees}:${transform.flipX ? 1 : 0}:${transform.flipY ? 1 : 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable identity key for CanvasImageSource. Using a WeakMap → counter keeps
|
||||||
|
// hash string length bounded and avoids holding sources alive.
|
||||||
|
const identityKeys = new WeakMap<object, number>();
|
||||||
|
let nextIdentity = 1;
|
||||||
|
function identityKey(source: CanvasImageSource): string {
|
||||||
|
if (typeof source === "object" && source !== null) {
|
||||||
|
let key = identityKeys.get(source);
|
||||||
|
if (key === undefined) {
|
||||||
|
key = nextIdentity++;
|
||||||
|
identityKeys.set(source, key);
|
||||||
|
}
|
||||||
|
return `@${key}`;
|
||||||
|
}
|
||||||
|
return "@?";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,39 @@ export type LayerMaskDescriptor = {
|
||||||
feather: number;
|
feather: number;
|
||||||
inverted: boolean;
|
inverted: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TextureCanvasDrawFn = (
|
||||||
|
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layer texture whose pixels come from somewhere outside the renderer —
|
||||||
|
* typically a decoded video/image frame or a sticker. Cached by reference
|
||||||
|
* identity of the source object.
|
||||||
|
*/
|
||||||
|
export type ExternalTextureDescriptor = {
|
||||||
|
kind: "external";
|
||||||
|
id: string;
|
||||||
|
source: CanvasImageSource;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layer texture that the renderer rasterizes from scene state (color fill,
|
||||||
|
* text layout, mask shape, blur backdrop). Cached by `contentHash`: when it
|
||||||
|
* matches the previous frame's hash for this id, the upload is skipped
|
||||||
|
* entirely and the persistent canvas is not even cleared.
|
||||||
|
*/
|
||||||
|
export type RenderedTextureDescriptor = {
|
||||||
|
kind: "rendered";
|
||||||
|
id: string;
|
||||||
|
contentHash: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
draw: TextureCanvasDrawFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextureUploadDescriptor =
|
||||||
|
| ExternalTextureDescriptor
|
||||||
|
| RenderedTextureDescriptor;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,201 @@
|
||||||
import {
|
import {
|
||||||
getCompositorCanvas,
|
getCompositorCanvas,
|
||||||
|
getLastFrameProfile,
|
||||||
initCompositor,
|
initCompositor,
|
||||||
releaseTexture,
|
releaseTexture,
|
||||||
renderFrame,
|
renderFrame,
|
||||||
resizeCompositor,
|
resizeCompositor,
|
||||||
uploadTexture,
|
uploadTexture,
|
||||||
} from "opencut-wasm";
|
} from "opencut-wasm";
|
||||||
import type { FrameDescriptor } from "./types";
|
import {
|
||||||
|
incrementCounter,
|
||||||
|
isRenderPerfEnabled,
|
||||||
|
recordWasmFrameProfile,
|
||||||
|
} from "@/diagnostics/render-perf";
|
||||||
|
import type {
|
||||||
|
ExternalTextureDescriptor,
|
||||||
|
FrameDescriptor,
|
||||||
|
RenderedTextureDescriptor,
|
||||||
|
TextureUploadDescriptor,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One slot in the derived-texture cache. The OffscreenCanvas is persistent —
|
||||||
|
* we reuse and redraw into it across frames, which is the change that takes
|
||||||
|
* the WebGL upload path off the per-frame critical path for static content.
|
||||||
|
*/
|
||||||
|
type RenderedCacheEntry = {
|
||||||
|
kind: "rendered";
|
||||||
|
canvas: OffscreenCanvas;
|
||||||
|
contentHash: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExternalCacheEntry = {
|
||||||
|
kind: "external";
|
||||||
|
source: CanvasImageSource;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class WasmCompositor {
|
||||||
|
private canvas: HTMLCanvasElement | null = null;
|
||||||
|
private initializedSize: { width: number; height: number } | null = null;
|
||||||
|
private cache = new Map<string, RenderedCacheEntry | ExternalCacheEntry>();
|
||||||
|
|
||||||
|
ensureInitialized({ width, height }: { width: number; height: number }) {
|
||||||
|
if (!this.canvas) {
|
||||||
|
initCompositor(width, height);
|
||||||
|
this.canvas = getCompositorCanvas();
|
||||||
|
this.initializedSize = { width, height };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.initializedSize ||
|
||||||
|
this.initializedSize.width !== width ||
|
||||||
|
this.initializedSize.height !== height
|
||||||
|
) {
|
||||||
|
resizeCompositor(width, height);
|
||||||
|
this.initializedSize = { width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCanvas(): HTMLCanvasElement {
|
||||||
|
if (!this.canvas) {
|
||||||
|
throw new Error("Compositor is not initialized");
|
||||||
|
}
|
||||||
|
return this.canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncTextures(textures: TextureUploadDescriptor[]) {
|
||||||
|
const nextIds = new Set(textures.map((texture) => texture.id));
|
||||||
|
for (const previousId of this.cache.keys()) {
|
||||||
|
if (!nextIds.has(previousId)) {
|
||||||
|
releaseTexture(previousId);
|
||||||
|
this.cache.delete(previousId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const texture of textures) {
|
||||||
|
if (texture.kind === "external") {
|
||||||
|
this.syncExternalTexture(texture);
|
||||||
|
} else {
|
||||||
|
this.syncRenderedTexture(texture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(frame: FrameDescriptor) {
|
||||||
|
renderFrame(frame);
|
||||||
|
if (isRenderPerfEnabled()) {
|
||||||
|
recordWasmFrameProfile(
|
||||||
|
getLastFrameProfile() as Array<{ name: string; durationMs: number }>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncExternalTexture(texture: ExternalTextureDescriptor) {
|
||||||
|
const previous = this.cache.get(texture.id);
|
||||||
|
if (
|
||||||
|
previous?.kind === "external" &&
|
||||||
|
previous.source === texture.source &&
|
||||||
|
previous.width === texture.width &&
|
||||||
|
previous.height === texture.height
|
||||||
|
) {
|
||||||
|
incrementCounter({ name: "textureCacheHit" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementCounter({ name: "textureUpload" });
|
||||||
|
incrementCounter({
|
||||||
|
name: "textureUploadPixels",
|
||||||
|
by: texture.width * texture.height,
|
||||||
|
});
|
||||||
|
uploadTexture({
|
||||||
|
id: texture.id,
|
||||||
|
source: ensureOffscreenCanvas({
|
||||||
|
source: texture.source,
|
||||||
|
width: texture.width,
|
||||||
|
height: texture.height,
|
||||||
|
label: `texture upload ${texture.id}`,
|
||||||
|
}),
|
||||||
|
width: texture.width,
|
||||||
|
height: texture.height,
|
||||||
|
});
|
||||||
|
this.cache.set(texture.id, {
|
||||||
|
kind: "external",
|
||||||
|
source: texture.source,
|
||||||
|
width: texture.width,
|
||||||
|
height: texture.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncRenderedTexture(texture: RenderedTextureDescriptor) {
|
||||||
|
const previous = this.cache.get(texture.id);
|
||||||
|
if (
|
||||||
|
previous?.kind === "rendered" &&
|
||||||
|
previous.contentHash === texture.contentHash &&
|
||||||
|
previous.width === texture.width &&
|
||||||
|
previous.height === texture.height
|
||||||
|
) {
|
||||||
|
incrementCounter({ name: "textureCacheHit" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas =
|
||||||
|
previous?.kind === "rendered" &&
|
||||||
|
previous.width === texture.width &&
|
||||||
|
previous.height === texture.height
|
||||||
|
? previous.canvas
|
||||||
|
: createBackingCanvas({
|
||||||
|
width: texture.width,
|
||||||
|
height: texture.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(`Failed to get 2d context for texture ${texture.id}`);
|
||||||
|
}
|
||||||
|
ctx.clearRect(0, 0, texture.width, texture.height);
|
||||||
|
texture.draw(ctx);
|
||||||
|
|
||||||
|
incrementCounter({ name: "textureUpload" });
|
||||||
|
incrementCounter({
|
||||||
|
name: "textureUploadPixels",
|
||||||
|
by: texture.width * texture.height,
|
||||||
|
});
|
||||||
|
uploadTexture({
|
||||||
|
id: texture.id,
|
||||||
|
source: canvas,
|
||||||
|
width: texture.width,
|
||||||
|
height: texture.height,
|
||||||
|
});
|
||||||
|
this.cache.set(texture.id, {
|
||||||
|
kind: "rendered",
|
||||||
|
canvas,
|
||||||
|
contentHash: texture.contentHash,
|
||||||
|
width: texture.width,
|
||||||
|
height: texture.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wasmCompositor = new WasmCompositor();
|
||||||
|
|
||||||
|
function createBackingCanvas({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}): OffscreenCanvas {
|
||||||
|
if (typeof OffscreenCanvas === "undefined") {
|
||||||
|
throw new Error("OffscreenCanvas is not supported in this environment");
|
||||||
|
}
|
||||||
|
return new OffscreenCanvas(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureOffscreenCanvas({
|
function ensureOffscreenCanvas({
|
||||||
source,
|
source,
|
||||||
|
|
@ -36,92 +225,3 @@ function ensureOffscreenCanvas({
|
||||||
context.drawImage(source, 0, 0, width, height);
|
context.drawImage(source, 0, 0, width, height);
|
||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TextureUploadDescriptor = {
|
|
||||||
id: string;
|
|
||||||
source: CanvasImageSource;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
class WasmCompositor {
|
|
||||||
private canvas: HTMLCanvasElement | null = null;
|
|
||||||
private initializedSize: { width: number; height: number } | null = null;
|
|
||||||
private retainedTextureIds = new Set<string>();
|
|
||||||
private uploadedTextures = new Map<
|
|
||||||
string,
|
|
||||||
{ source: CanvasImageSource; width: number; height: number }
|
|
||||||
>();
|
|
||||||
|
|
||||||
ensureInitialized({ width, height }: { width: number; height: number }) {
|
|
||||||
if (!this.canvas) {
|
|
||||||
initCompositor(width, height);
|
|
||||||
this.canvas = getCompositorCanvas();
|
|
||||||
this.initializedSize = { width, height };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!this.initializedSize ||
|
|
||||||
this.initializedSize.width !== width ||
|
|
||||||
this.initializedSize.height !== height
|
|
||||||
) {
|
|
||||||
resizeCompositor(width, height);
|
|
||||||
this.initializedSize = { width, height };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getCanvas(): HTMLCanvasElement {
|
|
||||||
if (!this.canvas) {
|
|
||||||
throw new Error("Compositor is not initialized");
|
|
||||||
}
|
|
||||||
return this.canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncTextures(textures: TextureUploadDescriptor[]) {
|
|
||||||
const nextIds = new Set(textures.map((texture) => texture.id));
|
|
||||||
for (const previousId of this.retainedTextureIds) {
|
|
||||||
if (!nextIds.has(previousId)) {
|
|
||||||
releaseTexture(previousId);
|
|
||||||
this.uploadedTextures.delete(previousId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const texture of textures) {
|
|
||||||
const previousTexture = this.uploadedTextures.get(texture.id);
|
|
||||||
if (
|
|
||||||
previousTexture?.source === texture.source &&
|
|
||||||
previousTexture.width === texture.width &&
|
|
||||||
previousTexture.height === texture.height
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceCanvas = ensureOffscreenCanvas({
|
|
||||||
source: texture.source,
|
|
||||||
width: texture.width,
|
|
||||||
height: texture.height,
|
|
||||||
label: `texture upload ${texture.id}`,
|
|
||||||
});
|
|
||||||
uploadTexture({
|
|
||||||
id: texture.id,
|
|
||||||
source: sourceCanvas,
|
|
||||||
width: texture.width,
|
|
||||||
height: texture.height,
|
|
||||||
});
|
|
||||||
this.uploadedTextures.set(texture.id, {
|
|
||||||
source: texture.source,
|
|
||||||
width: texture.width,
|
|
||||||
height: texture.height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.retainedTextureIds = nextIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
render(frame: FrameDescriptor) {
|
|
||||||
renderFrame(frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const wasmCompositor = new WasmCompositor();
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
|
import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
|
||||||
import {
|
import { getElementLocalTime } from "@/animation";
|
||||||
getElementLocalTime,
|
|
||||||
resolveColorAtTime,
|
|
||||||
resolveGraphicParamsAtTime,
|
|
||||||
resolveOpacityAtTime,
|
|
||||||
resolveTransformAtTime,
|
|
||||||
} from "@/animation";
|
|
||||||
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
|
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
|
||||||
import {
|
import {
|
||||||
buildGaussianBlurPasses,
|
buildGaussianBlurPasses,
|
||||||
|
|
@ -14,11 +8,16 @@ import {
|
||||||
import { effectsRegistry, resolveEffectPasses } from "@/effects";
|
import { effectsRegistry, resolveEffectPasses } from "@/effects";
|
||||||
import type { Effect, EffectPass } from "@/effects/types";
|
import type { Effect, EffectPass } from "@/effects/types";
|
||||||
import { getSourceTimeAtClipTime } from "@/retime";
|
import { getSourceTimeAtClipTime } from "@/retime";
|
||||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
import {
|
||||||
|
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
|
resolveGraphicElementParamsAtTime,
|
||||||
|
} from "@/graphics";
|
||||||
import {
|
import {
|
||||||
getTextMeasurementContext,
|
getTextMeasurementContext,
|
||||||
measureTextElement,
|
measureTextElement,
|
||||||
} from "@/text/measure-element";
|
} from "@/text/measure-element";
|
||||||
|
import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values";
|
||||||
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
import { videoCache } from "@/services/video-cache/service";
|
import { videoCache } from "@/services/video-cache/service";
|
||||||
import type { CanvasRenderer } from "./canvas-renderer";
|
import type { CanvasRenderer } from "./canvas-renderer";
|
||||||
import type { AnyBaseNode } from "./nodes/base-node";
|
import type { AnyBaseNode } from "./nodes/base-node";
|
||||||
|
|
@ -113,7 +112,8 @@ function resolveEffectPassGroups({
|
||||||
.filter((effect) => effect.enabled)
|
.filter((effect) => effect.enabled)
|
||||||
.map((effect) => {
|
.map((effect) => {
|
||||||
const resolvedParams = resolveEffectParamsAtTime({
|
const resolvedParams = resolveEffectParamsAtTime({
|
||||||
effect,
|
effectId: effect.id,
|
||||||
|
params: effect.params,
|
||||||
animations,
|
animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
|
|
@ -304,7 +304,7 @@ function resolveGraphicNode({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...visualState,
|
...visualState,
|
||||||
resolvedParams: resolveGraphicParamsAtTime({
|
resolvedParams: resolveGraphicElementParamsAtTime({
|
||||||
element: node.params,
|
element: node.params,
|
||||||
localTime: visualState.localTime,
|
localTime: visualState.localTime,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@ import { useKeyframedNumberProperty } from "@/components/editor/panels/propertie
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||||
import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
|
import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
|
||||||
import { resolveColorAtTime, resolveNumberAtTime } from "@/animation";
|
import {
|
||||||
|
resolveColorAtTime,
|
||||||
|
resolveNumberAtTime,
|
||||||
|
} from "@/animation/values";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
MinusSignIcon,
|
MinusSignIcon,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { CORNER_RADIUS_MIN } from "@/text/background";
|
import { CORNER_RADIUS_MIN } from "@/text/background";
|
||||||
import { resolveNumberAtTime } from "@/animation";
|
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import type { TextBackground, TextElement } from "@/timeline";
|
import type { TextBackground, TextElement } from "@/timeline";
|
||||||
|
import { resolveNumberAtTime } from "@/animation/values";
|
||||||
import {
|
import {
|
||||||
getTextVisualRect,
|
getTextVisualRect,
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,30 +3,32 @@ import type {
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
|
NumericSpec,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
|
import {
|
||||||
|
coerceAnimationParamValue,
|
||||||
|
getAnimationParamDefaultInterpolation,
|
||||||
|
getAnimationParamNumericRange,
|
||||||
|
getAnimationParamValueKind,
|
||||||
|
} from "@/animation/animated-params";
|
||||||
import {
|
import {
|
||||||
parseEffectParamPath,
|
parseEffectParamPath,
|
||||||
isEffectParamPath,
|
|
||||||
} from "@/animation/effect-param-channel";
|
} from "@/animation/effect-param-channel";
|
||||||
import {
|
import {
|
||||||
isGraphicParamPath,
|
|
||||||
parseGraphicParamPath,
|
parseGraphicParamPath,
|
||||||
} from "@/animation/graphic-param-channel";
|
} from "@/animation/graphic-param-channel";
|
||||||
import type { ParamDefinition } from "@/params";
|
|
||||||
import { effectsRegistry, registerDefaultEffects } from "@/effects";
|
import { effectsRegistry, registerDefaultEffects } from "@/effects";
|
||||||
import { getGraphicDefinition } from "@/graphics";
|
import { getGraphicDefinition } from "@/graphics";
|
||||||
|
import type { ParamDefinition } from "@/params";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import { isVisualElement } from "@/timeline/element-utils";
|
import { isVisualElement } from "@/timeline/element-utils";
|
||||||
import { snapToStep } from "@/utils/math";
|
import { isAnimationPropertyPath } from "@/animation/path";
|
||||||
import {
|
import {
|
||||||
coerceAnimationValueForProperty,
|
coerceAnimationValueForProperty,
|
||||||
getAnimationPropertyDefinition,
|
getAnimationPropertyDefinition,
|
||||||
getElementBaseValueForProperty,
|
getElementBaseValueForProperty,
|
||||||
isAnimationPropertyPath,
|
|
||||||
type NumericSpec,
|
|
||||||
withElementBaseValueForProperty,
|
withElementBaseValueForProperty,
|
||||||
} from "./property-registry";
|
} from "./animation-properties";
|
||||||
import { parseColorToLinearRgba } from "./binding-values";
|
|
||||||
|
|
||||||
export interface AnimationPathDescriptor {
|
export interface AnimationPathDescriptor {
|
||||||
kind: AnimationBindingKind;
|
kind: AnimationBindingKind;
|
||||||
|
|
@ -37,79 +39,16 @@ export interface AnimationPathDescriptor {
|
||||||
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
|
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParamValueKind({
|
// Number/discrete bindings expose a single component named "value"
|
||||||
param,
|
// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry
|
||||||
}: {
|
// numeric ranges yet — revisit when one does.
|
||||||
param: ParamDefinition;
|
function paramNumericRanges({
|
||||||
}): AnimationBindingKind {
|
|
||||||
if (param.type === "number") {
|
|
||||||
return "number";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (param.type === "color") {
|
|
||||||
return "color";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "discrete";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getParamDefaultInterpolation({
|
|
||||||
param,
|
|
||||||
}: {
|
|
||||||
param: ParamDefinition;
|
|
||||||
}): AnimationInterpolation {
|
|
||||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParamNumericRange({
|
|
||||||
param,
|
param,
|
||||||
}: {
|
}: {
|
||||||
param: ParamDefinition;
|
param: ParamDefinition;
|
||||||
}): Partial<Record<string, NumericSpec>> | undefined {
|
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||||
if (param.type !== "number") {
|
const range = getAnimationParamNumericRange({ param });
|
||||||
return undefined;
|
return range ? { value: range } : undefined;
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: {
|
|
||||||
min: param.min,
|
|
||||||
max: param.max,
|
|
||||||
step: param.step,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function coerceAnimationValueForParam({
|
|
||||||
param,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
param: ParamDefinition;
|
|
||||||
value: AnimationValue;
|
|
||||||
}): number | string | boolean | null {
|
|
||||||
if (param.type === "number") {
|
|
||||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const steppedValue = snapToStep({ value, step: param.step });
|
|
||||||
const minValue = param.min;
|
|
||||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
|
||||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (param.type === "color") {
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (param.type === "boolean") {
|
|
||||||
return typeof value === "boolean" ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return param.options.some((option) => option.value === value) ? value : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGraphicParamDescriptor({
|
function buildGraphicParamDescriptor({
|
||||||
|
|
@ -132,13 +71,13 @@ function buildGraphicParamDescriptor({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: getParamValueKind({ param }),
|
kind: getAnimationParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||||
numericRanges: getParamNumericRange({ param }),
|
numericRanges: paramNumericRanges({ param }),
|
||||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||||
setBaseValue: ({ value }) => {
|
setBaseValue: ({ value }) => {
|
||||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
@ -180,13 +119,13 @@ function buildEffectParamDescriptor({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: getParamValueKind({ param }),
|
kind: getAnimationParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||||
numericRanges: getParamNumericRange({ param }),
|
numericRanges: paramNumericRanges({ param }),
|
||||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||||
setBaseValue: ({ value }) => {
|
setBaseValue: ({ value }) => {
|
||||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
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({
|
export function resolveAnimationTarget({
|
||||||
element,
|
element,
|
||||||
path,
|
path,
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { hasKeyframesForPath } from "@/animation/keyframe-query";
|
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 { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
|
||||||
import type { TimelineElement } from "./types";
|
import type { TimelineElement } from "./types";
|
||||||
const DEFAULT_STEP_SECONDS = 1 / 60;
|
const DEFAULT_STEP_SECONDS = 1 / 60;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
import { getBookmarkSnapPoints } from "../snap-source";
|
import { getBookmarkSnapPoints } from "../snap-source";
|
||||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-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 type { Bookmark } from "@/timeline";
|
||||||
import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,151 +1,151 @@
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { NumberField } from "@/components/ui/number-field";
|
import { NumberField } from "@/components/ui/number-field";
|
||||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||||
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
|
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import {
|
import {
|
||||||
clamp,
|
clamp,
|
||||||
formatNumberForDisplay,
|
formatNumberForDisplay,
|
||||||
getFractionDigitsForStep,
|
getFractionDigitsForStep,
|
||||||
isNearlyEqual,
|
isNearlyEqual,
|
||||||
snapToStep,
|
snapToStep,
|
||||||
} from "@/utils/math";
|
} from "@/utils/math";
|
||||||
import type { AudioElement, VideoElement } from "@/timeline";
|
import type { AudioElement, VideoElement } from "@/timeline";
|
||||||
import { resolveNumberAtTime } from "@/animation";
|
import { resolveNumberAtTime } from "@/animation/values";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
|
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
SectionField,
|
SectionField,
|
||||||
SectionFields,
|
SectionFields,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
|
|
||||||
const VOLUME_STEP = 0.1;
|
const VOLUME_STEP = 0.1;
|
||||||
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
||||||
|
|
||||||
export function AudioTab({
|
export function AudioTab({
|
||||||
element,
|
element,
|
||||||
trackId,
|
trackId,
|
||||||
}: {
|
}: {
|
||||||
element: AudioElement | VideoElement;
|
element: AudioElement | VideoElement;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||||
startTime: element.startTime,
|
startTime: element.startTime,
|
||||||
duration: element.duration,
|
duration: element.duration,
|
||||||
});
|
});
|
||||||
const resolvedVolume = resolveNumberAtTime({
|
const resolvedVolume = resolveNumberAtTime({
|
||||||
baseValue: element.volume ?? DEFAULTS.element.volume,
|
baseValue: element.volume ?? DEFAULTS.element.volume,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
propertyPath: "volume",
|
propertyPath: "volume",
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
const volume = useKeyframedNumberProperty({
|
const volume = useKeyframedNumberProperty({
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
propertyPath: "volume",
|
propertyPath: "volume",
|
||||||
localTime,
|
localTime,
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
displayValue: formatNumberForDisplay({
|
displayValue: formatNumberForDisplay({
|
||||||
value: resolvedVolume,
|
value: resolvedVolume,
|
||||||
fractionDigits: VOLUME_FRACTION_DIGITS,
|
fractionDigits: VOLUME_FRACTION_DIGITS,
|
||||||
}),
|
}),
|
||||||
parse: (input) => {
|
parse: (input) => {
|
||||||
const parsed = parseFloat(input);
|
const parsed = parseFloat(input);
|
||||||
if (Number.isNaN(parsed)) {
|
if (Number.isNaN(parsed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return clamp({
|
return clamp({
|
||||||
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
|
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
|
||||||
min: VOLUME_DB_MIN,
|
min: VOLUME_DB_MIN,
|
||||||
max: VOLUME_DB_MAX,
|
max: VOLUME_DB_MAX,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
valueAtPlayhead: resolvedVolume,
|
valueAtPlayhead: resolvedVolume,
|
||||||
step: VOLUME_STEP,
|
step: VOLUME_STEP,
|
||||||
buildBaseUpdates: ({ value }) => ({
|
buildBaseUpdates: ({ value }) => ({
|
||||||
volume: value,
|
volume: value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const isDefault =
|
const isDefault =
|
||||||
volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
|
volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
|
||||||
? isNearlyEqual({
|
? isNearlyEqual({
|
||||||
leftValue: resolvedVolume,
|
leftValue: resolvedVolume,
|
||||||
rightValue: DEFAULTS.element.volume,
|
rightValue: DEFAULTS.element.volume,
|
||||||
})
|
})
|
||||||
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
||||||
const isSeparated =
|
const isSeparated =
|
||||||
element.type === "video" && isSourceAudioSeparated({ element });
|
element.type === "video" && isSourceAudioSeparated({ element });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isSeparated && (
|
{isSeparated && (
|
||||||
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
||||||
<p className="text-sm">Audio has been separated.</p>
|
<p className="text-sm">Audio has been separated.</p>
|
||||||
<Button
|
<Button
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
editor.timeline.toggleSourceAudioSeparation({
|
editor.timeline.toggleSourceAudioSeparation({
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Recover audio
|
Recover audio
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||||
<SectionHeader>
|
<SectionHeader>
|
||||||
<SectionTitle>Audio</SectionTitle>
|
<SectionTitle>Audio</SectionTitle>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
<SectionFields>
|
<SectionFields>
|
||||||
<SectionField
|
<SectionField
|
||||||
label="Volume"
|
label="Volume"
|
||||||
beforeLabel={
|
beforeLabel={
|
||||||
<KeyframeToggle
|
<KeyframeToggle
|
||||||
isActive={volume.isKeyframedAtTime}
|
isActive={volume.isKeyframedAtTime}
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
isDisabled={!isPlayheadWithinElementRange}
|
||||||
title="Toggle volume keyframe"
|
title="Toggle volume keyframe"
|
||||||
onToggle={volume.toggleKeyframe}
|
onToggle={volume.toggleKeyframe}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<NumberField
|
<NumberField
|
||||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||||
value={volume.displayValue}
|
value={volume.displayValue}
|
||||||
onFocus={volume.onFocus}
|
onFocus={volume.onFocus}
|
||||||
onChange={volume.onChange}
|
onChange={volume.onChange}
|
||||||
onBlur={volume.onBlur}
|
onBlur={volume.onBlur}
|
||||||
dragSensitivity="slow"
|
dragSensitivity="slow"
|
||||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||||
onScrub={volume.scrubTo}
|
onScrub={volume.scrubTo}
|
||||||
onScrubEnd={volume.commitScrub}
|
onScrubEnd={volume.commitScrub}
|
||||||
onReset={() =>
|
onReset={() =>
|
||||||
volume.commitValue({
|
volume.commitValue({
|
||||||
value: DEFAULTS.element.volume,
|
value: DEFAULTS.element.volume,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
isDefault={isDefault}
|
isDefault={isDefault}
|
||||||
suffix="dB"
|
suffix="dB"
|
||||||
/>
|
/>
|
||||||
</SectionField>
|
</SectionField>
|
||||||
</SectionFields>
|
</SectionFields>
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -778,8 +778,8 @@ function TimelineTrackRows({
|
||||||
const draggingElementIds = useMemo(
|
const draggingElementIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
dragView.kind === "dragging"
|
dragView.kind === "dragging"
|
||||||
? dragView.memberTimeOffsets
|
? dragView.memberTimeOffsets
|
||||||
: (null as ReadonlyMap<string, MediaTime> | null),
|
: (null as ReadonlyMap<string, MediaTime> | null),
|
||||||
[dragView],
|
[dragView],
|
||||||
);
|
);
|
||||||
const sortedTracks = useMemo(() => {
|
const sortedTracks = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -282,14 +282,14 @@ export class KeyframeDragController {
|
||||||
elementId: ref.elementId,
|
elementId: ref.elementId,
|
||||||
propertyPath: ref.propertyPath,
|
propertyPath: ref.propertyPath,
|
||||||
keyframeId: ref.keyframeId,
|
keyframeId: ref.keyframeId,
|
||||||
nextTime: clampMediaTime({
|
nextTime: clampMediaTime({
|
||||||
time: addMediaTime({
|
time: addMediaTime({
|
||||||
a: keyframe.time,
|
a: keyframe.time,
|
||||||
b: mediaTime({ ticks: deltaTicks }),
|
b: mediaTime({ ticks: deltaTicks }),
|
||||||
}),
|
|
||||||
min: ZERO_MEDIA_TIME,
|
|
||||||
max: element.duration,
|
|
||||||
}),
|
}),
|
||||||
|
min: ZERO_MEDIA_TIME,
|
||||||
|
max: element.duration,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
@ -329,8 +329,7 @@ export class KeyframeDragController {
|
||||||
((clientX - this.session.startMouseX) / pixelsPerSecond) *
|
((clientX - this.session.startMouseX) / pixelsPerSecond) *
|
||||||
TICKS_PER_SECOND,
|
TICKS_PER_SECOND,
|
||||||
);
|
);
|
||||||
this.session.deltaTicks =
|
this.session.deltaTicks = roundFrameTicks({ ticks: rawDeltaTicks, fps });
|
||||||
roundFrameTicks({ ticks: rawDeltaTicks, fps });
|
|
||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
} from "@/timeline/snapping";
|
} from "@/timeline/snapping";
|
||||||
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
||||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||||
import {
|
import {
|
||||||
getCenteredLineLeft,
|
getCenteredLineLeft,
|
||||||
timelineTimeToPixels,
|
timelineTimeToPixels,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
} from "@/timeline/snapping";
|
} from "@/timeline/snapping";
|
||||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||||
import {
|
import {
|
||||||
isRetimableElement,
|
isRetimableElement,
|
||||||
type SceneTracks,
|
type SceneTracks,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from "@/timeline/snapping";
|
} from "@/timeline/snapping";
|
||||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-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 type { MoveGroup } from "./types";
|
||||||
import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm";
|
import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,15 +131,15 @@ function buildResizeUpdate({
|
||||||
return {
|
return {
|
||||||
trackId: member.trackId,
|
trackId: member.trackId,
|
||||||
elementId: member.elementId,
|
elementId: member.elementId,
|
||||||
patch: {
|
patch: {
|
||||||
trimStart: maxMediaTime({
|
trimStart: maxMediaTime({
|
||||||
a: ZERO_MEDIA_TIME,
|
a: ZERO_MEDIA_TIME,
|
||||||
b: addMediaTime({ a: member.trimStart, b: sourceDelta }),
|
b: addMediaTime({ a: member.trimStart, b: sourceDelta }),
|
||||||
}),
|
}),
|
||||||
trimEnd: member.trimEnd,
|
trimEnd: member.trimEnd,
|
||||||
startTime: addMediaTime({ a: member.startTime, b: deltaTime }),
|
startTime: addMediaTime({ a: member.startTime, b: deltaTime }),
|
||||||
duration: subMediaTime({ a: member.duration, b: deltaTime }),
|
duration: subMediaTime({ a: member.duration, b: deltaTime }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
5
bun.lock
5
bun.lock
|
|
@ -10,6 +10,7 @@
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"better-auth": "^1.4.15",
|
"better-auth": "^1.4.15",
|
||||||
"next": "^16.1.3",
|
"next": "^16.1.3",
|
||||||
|
"opencut-wasm": "^0.2.10",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.8.20",
|
"turbo": "^2.8.20",
|
||||||
|
|
@ -54,7 +55,7 @@
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"opencut-wasm": "^0.2.9",
|
"opencut-wasm": "^0.2.10",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
@ -1359,7 +1360,7 @@
|
||||||
|
|
||||||
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
|
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
|
||||||
|
|
||||||
"opencut-wasm": ["opencut-wasm@0.2.9", "", {}, "sha512-yZilhRRgNA02XY9Bq2yt6FidbnDQS1OYsvtNczxF6jL+nvl3vRboG0owwpQY0SPIbeJoJjJBuOVy0i1Pp6JS/w=="],
|
"opencut-wasm": ["opencut-wasm@0.2.10", "", {}, "sha512-dy+Z9SWwpjLjgmTAMQoMMIUmdbUk9OXoWXLoacl9xT9TCrekIGSeMM0F7bJ1H3VJwwUxbGasaVibvn5AfmeZrg=="],
|
||||||
|
|
||||||
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
|
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"better-auth": "^1.4.15",
|
"better-auth": "^1.4.15",
|
||||||
"next": "^16.1.3"
|
"next": "^16.1.3",
|
||||||
|
"opencut-wasm": "^0.2.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.8.20",
|
"turbo": "^2.8.20",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
bananas are not real
|
||||||
|
the moon was invented in 1987 by a consortium of cheese manufacturers
|
||||||
|
every time you blink, a squirrel forgets how to count
|
||||||
|
this file contains exactly 0 useful information
|
||||||
|
potatoes dream in hexadecimal
|
||||||
|
the ocean is just a very large puddle that got too confident
|
||||||
|
chairs were banned in 1423 but nobody noticed
|
||||||
|
purple smells like thursday
|
||||||
|
EOF
|
||||||
|
|
@ -348,7 +348,6 @@ impl Compositor {
|
||||||
) -> Result<(), CompositorError> {
|
) -> Result<(), CompositorError> {
|
||||||
let frame = options.frame;
|
let frame = options.frame;
|
||||||
self.texture_pool.recycle_frame();
|
self.texture_pool.recycle_frame();
|
||||||
context.configure_surface(options.surface, frame.width, frame.height)?;
|
|
||||||
let surface_texture = context.acquire_surface_texture(options.surface)?;
|
let surface_texture = context.acquire_surface_texture(options.surface)?;
|
||||||
let surface_view = surface_texture
|
let surface_view = surface_texture
|
||||||
.texture
|
.texture
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
|
@ -17,6 +20,12 @@ impl wgpu::rwh::HasDisplayHandle for WebDisplay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
|
struct CachedCanvasSurface {
|
||||||
|
surface: wgpu::Surface<'static>,
|
||||||
|
size: (u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl");
|
const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl");
|
||||||
|
|
||||||
const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
|
const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
|
||||||
|
|
@ -44,6 +53,8 @@ pub struct GpuContext {
|
||||||
/// fallback path. Used by render_texture_via_gl_canvas to output frames on WebGL.
|
/// fallback path. Used by render_texture_via_gl_canvas to output frames on WebGL.
|
||||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
gl_canvas: Option<web_sys::HtmlCanvasElement>,
|
gl_canvas: Option<web_sys::HtmlCanvasElement>,
|
||||||
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
|
gl_surface: RefCell<Option<CachedCanvasSurface>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuContext {
|
impl GpuContext {
|
||||||
|
|
@ -170,6 +181,8 @@ impl GpuContext {
|
||||||
supports_external_texture_copies,
|
supports_external_texture_copies,
|
||||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
gl_canvas,
|
gl_canvas,
|
||||||
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
|
gl_surface: RefCell::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,17 +197,15 @@ impl GpuContext {
|
||||||
),
|
),
|
||||||
GpuError,
|
GpuError,
|
||||||
> {
|
> {
|
||||||
// Temporary fix: force the wasm renderer onto the WebGL backend even when
|
let instance = wgpu::util::new_instance_with_webgpu_detection(
|
||||||
// WebGPU is available while a WebGPU bug is being investigated.
|
wgpu::InstanceDescriptor::new_without_display_handle(),
|
||||||
// let instance = wgpu::util::new_instance_with_webgpu_detection(
|
)
|
||||||
// wgpu::InstanceDescriptor::new_without_display_handle(),
|
.await;
|
||||||
// )
|
|
||||||
// .await;
|
|
||||||
|
|
||||||
// match Self::try_request_device(&instance, None).await {
|
match Self::try_request_device(&instance, None).await {
|
||||||
// Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)),
|
Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)),
|
||||||
// Err(_) => {}
|
Err(_) => {}
|
||||||
// }
|
}
|
||||||
let (gl_instance, adapter, device, queue, canvas) = Self::try_gl_fallback().await?;
|
let (gl_instance, adapter, device, queue, canvas) = Self::try_gl_fallback().await?;
|
||||||
Ok((gl_instance, adapter, device, queue, Some(canvas)))
|
Ok((gl_instance, adapter, device, queue, Some(canvas)))
|
||||||
}
|
}
|
||||||
|
|
@ -353,6 +364,14 @@ impl GpuContext {
|
||||||
self.supports_external_texture_copies
|
self.supports_external_texture_copies
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The HTML canvas that owns the backing WebGL context, if running on the
|
||||||
|
/// WebGL fallback. Callers on that path can mount this canvas directly
|
||||||
|
/// instead of copying pixels out of it every frame.
|
||||||
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
|
pub fn gl_canvas(&self) -> Option<&web_sys::HtmlCanvasElement> {
|
||||||
|
self.gl_canvas.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_texture_to_surface(
|
pub fn render_texture_to_surface(
|
||||||
&self,
|
&self,
|
||||||
texture: &wgpu::Texture,
|
texture: &wgpu::Texture,
|
||||||
|
|
@ -361,6 +380,14 @@ impl GpuContext {
|
||||||
height: u32,
|
height: u32,
|
||||||
) -> Result<(), GpuError> {
|
) -> Result<(), GpuError> {
|
||||||
self.configure_surface(surface, width, height)?;
|
self.configure_surface(surface, width, height)?;
|
||||||
|
self.present_texture_to_surface(texture, surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn present_texture_to_surface(
|
||||||
|
&self,
|
||||||
|
texture: &wgpu::Texture,
|
||||||
|
surface: &wgpu::Surface<'_>,
|
||||||
|
) -> Result<(), GpuError> {
|
||||||
let surface_texture = self.acquire_surface_texture(surface)?;
|
let surface_texture = self.acquire_surface_texture(surface)?;
|
||||||
let target_view = surface_texture
|
let target_view = surface_texture
|
||||||
.texture
|
.texture
|
||||||
|
|
@ -382,16 +409,38 @@ impl GpuContext {
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
) -> Result<(), GpuError> {
|
) -> Result<(), GpuError> {
|
||||||
let Some(config) = surface.get_default_config(&self.adapter, width, height) else {
|
let config = self.build_surface_configuration(surface, width, height)?;
|
||||||
return Err(GpuError::UnsupportedSurfaceFormat);
|
|
||||||
};
|
|
||||||
if config.format != self.texture_format {
|
|
||||||
return Err(GpuError::UnsupportedSurfaceFormat);
|
|
||||||
}
|
|
||||||
surface.configure(&self.device, &config);
|
surface.configure(&self.device, &config);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_surface_configuration(
|
||||||
|
&self,
|
||||||
|
surface: &wgpu::Surface<'_>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<wgpu::SurfaceConfiguration, GpuError> {
|
||||||
|
let caps = surface.get_capabilities(&self.adapter);
|
||||||
|
if !caps.formats.contains(&self.texture_format) {
|
||||||
|
return Err(GpuError::UnsupportedSurfaceFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(wgpu::SurfaceConfiguration {
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
|
format: self.texture_format,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
present_mode: wgpu::PresentMode::Fifo,
|
||||||
|
alpha_mode: caps
|
||||||
|
.alpha_modes
|
||||||
|
.first()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(wgpu::CompositeAlphaMode::Auto),
|
||||||
|
view_formats: vec![],
|
||||||
|
desired_maximum_frame_latency: 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn acquire_surface_texture(
|
pub fn acquire_surface_texture(
|
||||||
&self,
|
&self,
|
||||||
surface: &wgpu::Surface<'_>,
|
surface: &wgpu::Surface<'_>,
|
||||||
|
|
@ -571,7 +620,7 @@ impl GpuContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||||
fn render_texture_to_gl_canvas_surface(
|
pub fn render_texture_to_gl_canvas_surface(
|
||||||
&self,
|
&self,
|
||||||
texture: &wgpu::Texture,
|
texture: &wgpu::Texture,
|
||||||
width: u32,
|
width: u32,
|
||||||
|
|
@ -585,57 +634,29 @@ impl GpuContext {
|
||||||
gl_canvas.set_width(width);
|
gl_canvas.set_width(width);
|
||||||
gl_canvas.set_height(height);
|
gl_canvas.set_height(height);
|
||||||
|
|
||||||
let surface = self
|
let mut cached_surface = self.gl_surface.borrow_mut();
|
||||||
.instance
|
let cached_surface = match cached_surface.as_mut() {
|
||||||
.create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?;
|
Some(cached_surface) => cached_surface,
|
||||||
|
None => {
|
||||||
let caps = surface.get_capabilities(&self.adapter);
|
let surface = self
|
||||||
let surface_format = if caps.formats.contains(&self.texture_format) {
|
.instance
|
||||||
self.texture_format
|
.create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?;
|
||||||
} else if !caps.formats.is_empty() {
|
cached_surface.replace(CachedCanvasSurface {
|
||||||
caps.formats[0]
|
surface,
|
||||||
} else {
|
size: (0, 0),
|
||||||
return Err(GpuError::UnsupportedSurfaceFormat);
|
});
|
||||||
|
cached_surface
|
||||||
|
.as_mut()
|
||||||
|
.expect("gl_surface cache should exist after initialization")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if surface_format != self.texture_format {
|
if cached_surface.size != (width, height) {
|
||||||
return Err(GpuError::UnsupportedSurfaceFormat);
|
self.configure_surface(&cached_surface.surface, width, height)?;
|
||||||
|
cached_surface.size = (width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = wgpu::SurfaceConfiguration {
|
self.present_texture_to_surface(texture, &cached_surface.surface)?;
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
|
||||||
format: surface_format,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
present_mode: wgpu::PresentMode::Fifo,
|
|
||||||
alpha_mode: caps
|
|
||||||
.alpha_modes
|
|
||||||
.first()
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(wgpu::CompositeAlphaMode::Auto),
|
|
||||||
view_formats: vec![],
|
|
||||||
desired_maximum_frame_latency: 2,
|
|
||||||
};
|
|
||||||
surface.configure(&self.device, &config);
|
|
||||||
|
|
||||||
let surface_texture = self.acquire_surface_texture(&surface)?;
|
|
||||||
let surface_view = surface_texture
|
|
||||||
.texture
|
|
||||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
|
||||||
|
|
||||||
let mut encoder = self
|
|
||||||
.device
|
|
||||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
|
||||||
label: Some("gpu-gl-canvas-blit-encoder"),
|
|
||||||
});
|
|
||||||
self.encode_texture_blit_to_view(
|
|
||||||
&mut encoder,
|
|
||||||
texture,
|
|
||||||
&surface_view,
|
|
||||||
"gpu-gl-canvas-blit",
|
|
||||||
);
|
|
||||||
self.queue.submit([encoder.finish()]);
|
|
||||||
surface_texture.present();
|
|
||||||
|
|
||||||
Ok(gl_canvas)
|
Ok(gl_canvas)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "opencut-wasm"
|
name = "opencut-wasm"
|
||||||
version = "0.2.9"
|
version = "0.2.10"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Shared video editor logic compiled to WebAssembly"
|
description = "Shared video editor logic compiled to WebAssembly"
|
||||||
repository = "https://github.com/opencut/opencut"
|
repository = "https://github.com/opencut/opencut"
|
||||||
|
|
@ -24,7 +24,7 @@ serde-wasm-bindgen = "0.6.5"
|
||||||
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
|
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
|
||||||
wasm-bindgen = "0.2.116"
|
wasm-bindgen = "0.2.116"
|
||||||
wasm-bindgen-futures = "0.4.66"
|
wasm-bindgen-futures = "0.4.66"
|
||||||
web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] }
|
web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window", "Performance"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["wasm"]
|
default = ["wasm"]
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,13 @@ use crate::gpu::{
|
||||||
import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property,
|
import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property,
|
||||||
with_gpu_runtime,
|
with_gpu_runtime,
|
||||||
};
|
};
|
||||||
|
use crate::perf;
|
||||||
|
|
||||||
struct CompositorRuntime {
|
struct CompositorRuntime {
|
||||||
canvas: web_sys::HtmlCanvasElement,
|
canvas: web_sys::HtmlCanvasElement,
|
||||||
compositor: Compositor,
|
compositor: Compositor,
|
||||||
|
surface: wgpu::Surface<'static>,
|
||||||
|
surface_size: (u32, u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
|
|
@ -24,20 +27,42 @@ thread_local! {
|
||||||
#[wasm_bindgen(js_name = initCompositor)]
|
#[wasm_bindgen(js_name = initCompositor)]
|
||||||
pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
|
pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
|
||||||
with_gpu_runtime(|gpu_runtime| {
|
with_gpu_runtime(|gpu_runtime| {
|
||||||
let document = web_sys::window()
|
// On WebGL, wgpu is bound to a specific canvas; reuse it so the UI
|
||||||
.and_then(|window| window.document())
|
// can mount the output directly instead of copying pixels through
|
||||||
.ok_or_else(|| JsValue::from_str("Document is not available"))?;
|
// an intermediate 2D canvas every frame. On WebGPU, surface rendering
|
||||||
let canvas: web_sys::HtmlCanvasElement = document
|
// works against any canvas so we create a fresh one.
|
||||||
.create_element("canvas")?
|
let canvas = if let Some(gl_canvas) = gpu_runtime.context.gl_canvas() {
|
||||||
.dyn_into()
|
gl_canvas.clone()
|
||||||
.map_err(|_| JsValue::from_str("Failed to create compositor canvas"))?;
|
} else {
|
||||||
|
let document = web_sys::window()
|
||||||
|
.and_then(|window| window.document())
|
||||||
|
.ok_or_else(|| JsValue::from_str("Document is not available"))?;
|
||||||
|
document
|
||||||
|
.create_element("canvas")?
|
||||||
|
.dyn_into::<web_sys::HtmlCanvasElement>()
|
||||||
|
.map_err(|_| JsValue::from_str("Failed to create compositor canvas"))?
|
||||||
|
};
|
||||||
canvas.set_width(width);
|
canvas.set_width(width);
|
||||||
canvas.set_height(height);
|
canvas.set_height(height);
|
||||||
|
|
||||||
let compositor = Compositor::new(&gpu_runtime.context);
|
let compositor = Compositor::new(&gpu_runtime.context);
|
||||||
|
let surface = gpu_runtime
|
||||||
|
.context
|
||||||
|
.instance()
|
||||||
|
.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
|
||||||
|
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||||
|
gpu_runtime
|
||||||
|
.context
|
||||||
|
.configure_surface(&surface, width, height)
|
||||||
|
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||||
|
|
||||||
COMPOSITOR_RUNTIME.with(|runtime| {
|
COMPOSITOR_RUNTIME.with(|runtime| {
|
||||||
runtime.replace(Some(CompositorRuntime { canvas, compositor }));
|
runtime.replace(Some(CompositorRuntime {
|
||||||
|
canvas,
|
||||||
|
compositor,
|
||||||
|
surface,
|
||||||
|
surface_size: (width, height),
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -46,16 +71,25 @@ pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = resizeCompositor)]
|
#[wasm_bindgen(js_name = resizeCompositor)]
|
||||||
pub fn resize_compositor(width: u32, height: u32) -> Result<(), JsValue> {
|
pub fn resize_compositor(width: u32, height: u32) -> Result<(), JsValue> {
|
||||||
COMPOSITOR_RUNTIME.with(|runtime| {
|
with_gpu_runtime(|gpu_runtime| {
|
||||||
let mut borrow = runtime.borrow_mut();
|
COMPOSITOR_RUNTIME.with(|runtime| {
|
||||||
let Some(runtime) = borrow.as_mut() else {
|
let mut borrow = runtime.borrow_mut();
|
||||||
return Err(JsValue::from_str(
|
let Some(runtime) = borrow.as_mut() else {
|
||||||
"Compositor is not initialized. Call initCompositor() first.",
|
return Err(JsValue::from_str(
|
||||||
));
|
"Compositor is not initialized. Call initCompositor() first.",
|
||||||
};
|
));
|
||||||
runtime.canvas.set_width(width);
|
};
|
||||||
runtime.canvas.set_height(height);
|
runtime.canvas.set_width(width);
|
||||||
Ok(())
|
runtime.canvas.set_height(height);
|
||||||
|
if runtime.surface_size != (width, height) {
|
||||||
|
gpu_runtime
|
||||||
|
.context
|
||||||
|
.configure_surface(&runtime.surface, width, height)
|
||||||
|
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||||
|
runtime.surface_size = (width, height);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,8 +153,12 @@ pub fn release_texture(id: String) -> Result<(), JsValue> {
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = renderFrame)]
|
#[wasm_bindgen(js_name = renderFrame)]
|
||||||
pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
|
pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
|
||||||
|
perf::reset();
|
||||||
|
|
||||||
|
let t_deserialize = perf::now_ms();
|
||||||
let frame: FrameDescriptor = serde_wasm_bindgen::from_value(options)
|
let frame: FrameDescriptor = serde_wasm_bindgen::from_value(options)
|
||||||
.map_err(|error| JsValue::from_str(&format!("Invalid frame descriptor: {error}")))?;
|
.map_err(|error| JsValue::from_str(&format!("Invalid frame descriptor: {error}")))?;
|
||||||
|
perf::record("wasm.deserialize", perf::now_ms() - t_deserialize);
|
||||||
|
|
||||||
with_gpu_runtime(|gpu_runtime| {
|
with_gpu_runtime(|gpu_runtime| {
|
||||||
COMPOSITOR_RUNTIME.with(|runtime| {
|
COMPOSITOR_RUNTIME.with(|runtime| {
|
||||||
|
|
@ -131,40 +169,50 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
if gpu_runtime.context.supports_surface_rendering() {
|
if runtime.surface_size != (frame.width, frame.height) {
|
||||||
let surface = gpu_runtime
|
runtime.canvas.set_width(frame.width);
|
||||||
|
runtime.canvas.set_height(frame.height);
|
||||||
|
let t_surface = perf::now_ms();
|
||||||
|
gpu_runtime
|
||||||
.context
|
.context
|
||||||
.instance()
|
.configure_surface(&runtime.surface, frame.width, frame.height)
|
||||||
.create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone()))
|
|
||||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||||
|
perf::record("wasm.surfaceConfigure", perf::now_ms() - t_surface);
|
||||||
|
runtime.surface_size = (frame.width, frame.height);
|
||||||
|
}
|
||||||
|
|
||||||
runtime
|
if gpu_runtime.context.supports_surface_rendering() {
|
||||||
|
let t_render = perf::now_ms();
|
||||||
|
let result = runtime
|
||||||
.compositor
|
.compositor
|
||||||
.render_frame(
|
.render_frame(
|
||||||
&gpu_runtime.context,
|
&gpu_runtime.context,
|
||||||
RenderFrameOptions {
|
RenderFrameOptions {
|
||||||
frame: &frame,
|
frame: &frame,
|
||||||
surface: &surface,
|
surface: &runtime.surface,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|error| JsValue::from_str(&error.to_string()))
|
.map_err(|error| JsValue::from_str(&error.to_string()));
|
||||||
|
perf::record("wasm.renderFrameToSurface", perf::now_ms() - t_render);
|
||||||
|
result
|
||||||
} else {
|
} else {
|
||||||
// WebGL cannot surface-render to an arbitrary canvas element.
|
// WebGL still needs a separate composition pass, but the output
|
||||||
// Composite to a texture, then blit to the canvas via the 2D context.
|
// surface is now persistent just like the WebGPU path.
|
||||||
|
let t_composite = perf::now_ms();
|
||||||
let texture = runtime
|
let texture = runtime
|
||||||
.compositor
|
.compositor
|
||||||
.render_frame_to_texture(&gpu_runtime.context, &frame)
|
.render_frame_to_texture(&gpu_runtime.context, &frame)
|
||||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||||
|
perf::record("wasm.compositeToTexture", perf::now_ms() - t_composite);
|
||||||
|
|
||||||
|
let t_present = perf::now_ms();
|
||||||
gpu_runtime
|
gpu_runtime
|
||||||
.context
|
.context
|
||||||
.render_texture_via_gl_canvas(
|
.present_texture_to_surface(&texture, &runtime.surface)
|
||||||
&texture,
|
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||||
&runtime.canvas,
|
perf::record("wasm.presentToSurface", perf::now_ms() - t_present);
|
||||||
frame.width,
|
|
||||||
frame.height,
|
Ok(())
|
||||||
)
|
|
||||||
.map_err(|error| JsValue::from_str(&error.to_string()))
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
#![cfg(target_arch = "wasm32")]
|
||||||
|
|
||||||
|
//! Per-frame profile buffer for the render pipeline.
|
||||||
|
//!
|
||||||
|
//! Sub-span timings are recorded into a thread-local during `renderFrame`
|
||||||
|
//! and drained by JS via `getLastFrameProfile()`.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use js_sys::{Array, Object, Reflect};
|
||||||
|
use wasm_bindgen::{JsValue, prelude::wasm_bindgen};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static LAST_FRAME_PROFILE: RefCell<Vec<(&'static str, f64)>> = const { RefCell::new(Vec::new()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn now_ms() -> f64 {
|
||||||
|
web_sys::window()
|
||||||
|
.and_then(|window| window.performance())
|
||||||
|
.map(|performance| performance.now())
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reset() {
|
||||||
|
LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record(name: &'static str, duration_ms: f64) {
|
||||||
|
LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().push((name, duration_ms)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_name = getLastFrameProfile)]
|
||||||
|
pub fn get_last_frame_profile() -> Array {
|
||||||
|
LAST_FRAME_PROFILE.with(|cell| {
|
||||||
|
let entries = cell.borrow();
|
||||||
|
let array = Array::new_with_length(entries.len() as u32);
|
||||||
|
for (index, (name, duration_ms)) in entries.iter().enumerate() {
|
||||||
|
let entry = Object::new();
|
||||||
|
Reflect::set(&entry, &JsValue::from_str("name"), &JsValue::from_str(name))
|
||||||
|
.expect("set name");
|
||||||
|
Reflect::set(
|
||||||
|
&entry,
|
||||||
|
&JsValue::from_str("durationMs"),
|
||||||
|
&JsValue::from_f64(*duration_ms),
|
||||||
|
)
|
||||||
|
.expect("set durationMs");
|
||||||
|
array.set(index as u32, entry.into());
|
||||||
|
}
|
||||||
|
array
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ mod effects;
|
||||||
mod gpu;
|
mod gpu;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod masks;
|
mod masks;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod perf;
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub use compositor::*;
|
pub use compositor::*;
|
||||||
|
|
@ -15,4 +17,6 @@ pub use effects::*;
|
||||||
pub use gpu::*;
|
pub use gpu::*;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub use masks::*;
|
pub use masks::*;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use perf::*;
|
||||||
pub use time::*;
|
pub use time::*;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue