summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock2
-rw-r--r--README.md311
-rw-r--r--apps/web/package.json2
-rw-r--r--apps/web/src/actions/use-editor-actions.ts92
-rw-r--r--apps/web/src/animation/curve-bridge.ts5
-rw-r--r--apps/web/src/animation/index.ts13
-rw-r--r--apps/web/src/animation/interpolation.ts17
-rw-r--r--apps/web/src/animation/keyframes.ts106
-rw-r--r--apps/web/src/animation/property-registry.ts390
-rw-r--r--apps/web/src/animation/types.ts9
-rw-r--r--apps/web/src/clipboard/handlers/keyframes.ts6
-rw-r--r--apps/web/src/clipboard/types.ts5
-rw-r--r--apps/web/src/commands/scene/move-bookmark.ts5
-rw-r--r--apps/web/src/commands/scene/remove-bookmark.ts5
-rw-r--r--apps/web/src/commands/scene/toggle-bookmark.ts5
-rw-r--r--apps/web/src/commands/scene/update-bookmark.ts3
-rw-r--r--apps/web/src/commands/timeline/clipboard/paste-keyframes.ts24
-rw-r--r--apps/web/src/commands/timeline/clipboard/paste.ts45
-rw-r--r--apps/web/src/commands/timeline/element/duplicate-elements.ts3
-rw-r--r--apps/web/src/commands/timeline/element/insert-element.ts8
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts15
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts15
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts15
-rw-r--r--apps/web/src/commands/timeline/element/split-elements.ts64
-rw-r--r--apps/web/src/components/editable-timecode.tsx25
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx481
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx6
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts17
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts191
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts349
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts3
-rw-r--r--apps/web/src/core/managers/clipboard-manager.ts5
-rw-r--r--apps/web/src/core/managers/playback-manager.ts73
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts19
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts32
-rw-r--r--apps/web/src/graphics/components/graphic-tab.tsx480
-rw-r--r--apps/web/src/media/use-paste-media.ts4
-rw-r--r--apps/web/src/preview/components/toolbar.tsx5
-rw-r--r--apps/web/src/project/types.ts5
-rw-r--r--apps/web/src/rendering/components/blending-tab.tsx5
-rw-r--r--apps/web/src/services/renderer/resolve.ts6
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts257
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts228
-rw-r--r--apps/web/src/services/storage/migrations/v27-to-v28.ts14
-rw-r--r--apps/web/src/services/storage/service.ts31
-rw-r--r--apps/web/src/sounds/sounds-store.ts3
-rw-r--r--apps/web/src/subtitles/build-subtitle-text-element.ts6
-rw-r--r--apps/web/src/text/components/assets-view.tsx3
-rw-r--r--apps/web/src/timeline/__tests__/update-pipeline.test.ts66
-rw-r--r--apps/web/src/timeline/animation-properties.ts3
-rw-r--r--apps/web/src/timeline/animation-snap-points.ts3
-rw-r--r--apps/web/src/timeline/bookmarks/components/bookmarks.tsx43
-rw-r--r--apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts49
-rw-r--r--apps/web/src/timeline/bookmarks/preview-overlay-source.tsx5
-rw-r--r--apps/web/src/timeline/bookmarks/snap-source.ts3
-rw-r--r--apps/web/src/timeline/bookmarks/utils.ts32
-rw-r--r--apps/web/src/timeline/components/drop-target.ts33
-rw-r--r--apps/web/src/timeline/components/index.tsx19
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx32
-rw-r--r--apps/web/src/timeline/components/timeline-playhead.tsx32
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts10
-rw-r--r--apps/web/src/timeline/controllers/element-interaction-controller.ts47
-rw-r--r--apps/web/src/timeline/controllers/keyframe-drag-controller.ts69
-rw-r--r--apps/web/src/timeline/controllers/playhead-controller.ts30
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts77
-rw-r--r--apps/web/src/timeline/controllers/seek-controller.ts22
-rw-r--r--apps/web/src/timeline/controllers/zoom-controller.ts9
-rw-r--r--apps/web/src/timeline/creation.ts23
-rw-r--r--apps/web/src/timeline/defaults.ts9
-rw-r--r--apps/web/src/timeline/drag-utils.ts8
-rw-r--r--apps/web/src/timeline/element-snap-source.ts3
-rw-r--r--apps/web/src/timeline/element-utils.ts63
-rw-r--r--apps/web/src/timeline/group-move/build-group.ts6
-rw-r--r--apps/web/src/timeline/group-move/resolve-move.ts71
-rw-r--r--apps/web/src/timeline/group-move/snap.ts31
-rw-r--r--apps/web/src/timeline/group-move/types.ts7
-rw-r--r--apps/web/src/timeline/group-resize/compute-resize.ts274
-rw-r--r--apps/web/src/timeline/group-resize/types.ts27
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts5
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-playhead.ts13
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts5
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts15
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts13
-rw-r--r--apps/web/src/timeline/index.ts61
-rw-r--r--apps/web/src/timeline/placement/__tests__/resolve.test.ts39
-rw-r--r--apps/web/src/timeline/placement/main-track.ts9
-rw-r--r--apps/web/src/timeline/placement/resolve.ts3
-rw-r--r--apps/web/src/timeline/placement/types.ts7
-rw-r--r--apps/web/src/timeline/playhead-snap-source.ts3
-rw-r--r--apps/web/src/timeline/scenes.ts5
-rw-r--r--apps/web/src/timeline/snapping/resolve.ts3
-rw-r--r--apps/web/src/timeline/snapping/types.ts6
-rw-r--r--apps/web/src/timeline/types.ts649
-rw-r--r--apps/web/src/timeline/update-pipeline.ts16
-rw-r--r--apps/web/src/wasm/index.ts2
-rw-r--r--apps/web/src/wasm/media-time.ts190
-rw-r--r--apps/web/src/wasm/ticks.ts3
-rw-r--r--bun.lock9
-rw-r--r--package.json3
-rw-r--r--random_gibberish.txt9
-rw-r--r--rust/wasm/Cargo.toml2
102 files changed, 3654 insertions, 1939 deletions
diff --git a/Cargo.lock b/Cargo.lock
index f3bd3c00..04c00a6c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3820,7 +3820,7 @@ dependencies = [
[[package]]
name = "opencut-wasm"
-version = "0.2.9"
+version = "0.2.10"
dependencies = [
"bridge",
"compositor",
diff --git a/README.md b/README.md
index 3cb67abf..5fe7c25f 100644
--- a/README.md
+++ b/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" />
- </td>
- <td align="right">
- <h1>OpenCut</h1>
- <h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
- </td>
- </tr>
-</table>
-
-## Sponsors
-
-Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
-
-<a href="https://vercel.com/oss">
- <img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
-</a>
-
-<a href="https://fal.ai">
- <img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
-</a>
-
-## Why?
-
-- **Privacy**: Your videos stay on your device
-- **Free features**: Most basic CapCut features are now paywalled
-- **Simple**: People want editors that are easy to use - CapCut proved that
-
-## Project Structure
-
-- `apps/web/`: Next.js web application
-- `apps/desktop/`: Native desktop app built with GPUI (in progress)
-- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
-- `docs/`: Architecture and subsystem documentation
-
-## Getting Started
-
-### Prerequisites
-
-- [Bun](https://bun.sh/docs/installation)
-- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
-
-> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
-
-### Setup
-
-1. Fork and clone the repository
-
-2. Copy the environment file:
-
- ```bash
- # Unix/Linux/Mac
- cp apps/web/.env.example apps/web/.env.local
-
- # Windows PowerShell
- Copy-Item apps/web/.env.example apps/web/.env.local
- ```
-
-3. Start the database and Redis:
-
- ```bash
- docker compose up -d db redis serverless-redis-http
- ```
-
-4. Install dependencies and start the dev server:
-
- ```bash
- bun install
- bun dev:web
- ```
-
-The application will be available at [http://localhost:3000](http://localhost:3000).
-
-The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
-
-### Desktop setup
-
-Desktop is opt-in. If you're only working on the web app, skip this entirely.
-
-If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
-
-### Local WASM development
-
-Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
-
-**Prerequisites** — install these once before anything else:
-
-```bash
-# Rust toolchain
-curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
-
-# build the WASM package
-cargo install wasm-pack
-
-# reruns the build on file changes, used by bun dev:wasm
-cargo install cargo-watch
-```
-
-1. Build the package once from the repo root:
-
- ```bash
- bun run build:wasm
- ```
-
-2. Register the generated package for linking:
-
- ```bash
- cd rust/wasm/pkg
- bun link
- ```
-
-3. Link `apps/web` to the local package:
-
- ```bash
- cd apps/web
- bun link opencut-wasm
- ```
-
-4. Rebuild on changes while you work:
-
- ```bash
- bun dev:wasm
- ```
-
-To switch `apps/web` back to the published package, run:
-
-```bash
-cd apps/web
-bun add opencut-wasm
-```
-
-### Self-Hosting with Docker
-
-To run everything (including a production build of the app) in Docker:
-
-```bash
-docker compose up -d
-```
-
-The app will be available at [http://localhost:3100](http://localhost:3100).
-
-## Contributing
-
-We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
-
-**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
-
-**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
-
-See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
-
-**Quick start for contributors:**
-
-- Fork the repo and clone locally
-- Follow the setup instructions in CONTRIBUTING.md
-- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
-- Create a feature branch and submit a PR
-
-## License
-
-[MIT LICENSE](LICENSE)
-
----
-
-![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
+
+| | |
+| --- | ---------------------------------------------------------------------- |
+| | OpenCutA free, open-source video editor for web, desktop, and mobile. |
+
+
+## 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?
+
+- **Privacy**: Your videos stay on your device
+- **Free features**: Most basic CapCut features are now paywalled
+- **Simple**: People want editors that are easy to use - CapCut proved that
+
+## Project Structure
+
+- `apps/web/`: Next.js web application
+- `apps/desktop/`: Native desktop app built with GPUI (in progress)
+- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
+- `docs/`: Architecture and subsystem documentation
+
+## Getting Started
+
+### Prerequisites
+
+- [Bun](https://bun.sh/docs/installation)
+- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
+
+> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
+
+### Setup
+
+1. Fork and clone the repository
+2. Copy the environment file:
+ ```bash
+ # Unix/Linux/Mac
+ cp apps/web/.env.example apps/web/.env.local
+
+ # Windows PowerShell
+ Copy-Item apps/web/.env.example apps/web/.env.local
+ ```
+3. Start the database and Redis:
+ ```bash
+ docker compose up -d db redis serverless-redis-http
+ ```
+4. Install dependencies and start the dev server:
+ ```bash
+ bun install
+ bun dev:web
+ ```
+
+The application will be available at [http://localhost:3000](http://localhost:3000).
+
+The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
+
+### Desktop setup
+
+Desktop is opt-in. If you're only working on the web app, skip this entirely.
+
+If you want to get ready for `apps/desktop`, see `[apps/desktop/README.md](apps/desktop/README.md)`. It's a two-step setup: Rust toolchain first, then desktop native dependencies.
+
+### Local WASM development
+
+Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
+
+**Prerequisites** — install these once before anything else:
+
+```bash
+# Rust toolchain
+curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
+
+# build the WASM package
+cargo install wasm-pack
+
+# reruns the build on file changes, used by bun dev:wasm
+cargo install cargo-watch
+```
+
+1. Build the package once from the repo root:
+ ```bash
+ bun run build:wasm
+ ```
+2. Register the generated package for linking:
+ ```bash
+ cd rust/wasm/pkg
+ bun link
+ ```
+3. Link `apps/web` to the local package:
+ ```bash
+ cd apps/web
+ bun link opencut-wasm
+ ```
+4. Rebuild on changes while you work:
+ ```bash
+ bun dev:wasm
+ ```
+
+To switch `apps/web` back to the published package, run:
+
+```bash
+cd apps/web
+bun add opencut-wasm
+```
+
+### Self-Hosting with Docker
+
+To run everything (including a production build of the app) in Docker:
+
+```bash
+docker compose up -d
+```
+
+The app will be available at [http://localhost:3100](http://localhost:3100).
+
+## Contributing
+
+We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
+
+**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
+
+**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
+
+See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
+
+**Quick start for contributors:**
+
+- Fork the repo and clone locally
+- Follow the setup instructions in CONTRIBUTING.md
+- Working on `apps/desktop`? See `[apps/desktop/README.md](apps/desktop/README.md)` for setup
+- Create a feature branch and submit a PR
+
+## License
+
+[MIT LICENSE](LICENSE)
+
+---
+
+Star History Chart \ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index 0034ebad..bbc3ad0a 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -52,7 +52,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
- "opencut-wasm": "^0.2.9",
+ "opencut-wasm": "^0.2.10",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts
index e62a8ce0..5e90a6a1 100644
--- a/apps/web/src/actions/use-editor-actions.ts
+++ b/apps/web/src/actions/use-editor-actions.ts
@@ -5,7 +5,16 @@ import { useTimelineStore } from "@/timeline/timeline-store";
import { useActionHandler } from "@/actions/use-action-handler";
import { useEditor } from "@/editor/use-editor";
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ maxMediaTime,
+ mediaTime,
+ mediaTimeFromSeconds,
+ minMediaTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
import { getElementsAtTime, hasMediaId } from "@/timeline";
import { cancelInteraction } from "@/editor/cancel-interaction";
@@ -83,7 +92,7 @@ export function useEditorActions() {
if (editor.playback.getIsPlaying()) {
editor.playback.toggle();
}
- editor.playback.seek({ time: 0 });
+ editor.playback.seek({ time: ZERO_MEDIA_TIME });
},
undefined,
);
@@ -92,11 +101,15 @@ export function useEditorActions() {
"seek-forward",
(args) => {
const seconds = args?.seconds ?? 1;
+ const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
- time: Math.min(
- editor.timeline.getTotalDuration(),
- editor.playback.getCurrentTime() + seconds,
- ),
+ time: minMediaTime({
+ a: editor.timeline.getTotalDuration(),
+ b: addMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: delta,
+ }),
+ }),
});
},
undefined,
@@ -106,8 +119,15 @@ export function useEditorActions() {
"seek-backward",
(args) => {
const seconds = args?.seconds ?? 1;
+ const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ time: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: delta,
+ }),
+ }),
});
},
undefined,
@@ -117,15 +137,20 @@ export function useEditorActions() {
"frame-step-forward",
() => {
const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
- editor.playback.seek({
- time: Math.min(
- editor.timeline.getTotalDuration(),
- editor.playback.getCurrentTime() + ticksPerFrame,
+ const ticksPerFrame = mediaTime({
+ ticks: Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
+ editor.playback.seek({
+ time: minMediaTime({
+ a: editor.timeline.getTotalDuration(),
+ b: addMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: ticksPerFrame,
+ }),
+ }),
+ });
},
undefined,
);
@@ -134,11 +159,19 @@ export function useEditorActions() {
"frame-step-backward",
() => {
const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
+ const ticksPerFrame = mediaTime({
+ ticks: Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ ),
+ });
editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
+ time: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: ticksPerFrame,
+ }),
+ }),
});
},
undefined,
@@ -148,11 +181,15 @@ export function useEditorActions() {
"jump-forward",
(args) => {
const seconds = args?.seconds ?? 5;
+ const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
- time: Math.min(
- editor.timeline.getTotalDuration(),
- editor.playback.getCurrentTime() + seconds,
- ),
+ time: minMediaTime({
+ a: editor.timeline.getTotalDuration(),
+ b: addMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: delta,
+ }),
+ }),
});
},
undefined,
@@ -162,8 +199,15 @@ export function useEditorActions() {
"jump-backward",
(args) => {
const seconds = args?.seconds ?? 5;
+ const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ time: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: delta,
+ }),
+ }),
});
},
undefined,
@@ -172,7 +216,7 @@ export function useEditorActions() {
useActionHandler(
"goto-start",
() => {
- editor.playback.seek({ time: 0 });
+ editor.playback.seek({ time: ZERO_MEDIA_TIME });
},
undefined,
);
diff --git a/apps/web/src/animation/curve-bridge.ts b/apps/web/src/animation/curve-bridge.ts
index af411dd1..c947e7dc 100644
--- a/apps/web/src/animation/curve-bridge.ts
+++ b/apps/web/src/animation/curve-bridge.ts
@@ -7,6 +7,7 @@ import type {
NormalizedCubicBezier,
ScalarAnimationKey,
} from "@/animation/types";
+import { roundMediaTime } from "@/wasm";
const VALUE_EPSILON = 1e-6;
@@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
return {
rightHandle: {
- dt: spanTime * x1,
+ dt: roundMediaTime({ time: spanTime * x1 }),
dv: effectiveSpanValue * y1,
},
leftHandle: {
- dt: spanTime * (x2 - 1),
+ dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
dv: effectiveSpanValue * (y2 - 1),
},
};
diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts
index af3b910d..33e637c3 100644
--- a/apps/web/src/animation/index.ts
+++ b/apps/web/src/animation/index.ts
@@ -16,6 +16,7 @@ export {
setChannel,
splitAnimationsAtTime,
updateScalarKeyframeCurve,
+ upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
@@ -71,4 +72,16 @@ export {
export {
isAnimationPath,
+ isAnimationPropertyPath,
} from "./path";
+
+export {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getDefaultInterpolationForProperty,
+ getElementBaseValueForProperty,
+ supportsAnimationProperty,
+ type AnimationPropertyDefinition,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts
index 6bab66c4..5d5212a6 100644
--- a/apps/web/src/animation/interpolation.ts
+++ b/apps/web/src/animation/interpolation.ts
@@ -8,6 +8,7 @@ import type {
ScalarAnimationKey,
ScalarSegmentType,
} from "@/animation/types";
+import { mediaTime } from "@/wasm";
import {
getBezierPoint,
getDefaultLeftHandle,
@@ -63,9 +64,13 @@ function normalizeRightHandle({
return undefined;
}
- const span = Math.max(1, rightKey.time - leftKey.time);
+ const span = mediaTime({
+ ticks: Math.max(1, rightKey.time - leftKey.time),
+ });
return {
- dt: Math.min(span, Math.max(0, handle.dt)),
+ dt: mediaTime({
+ ticks: Math.min(span, Math.max(0, handle.dt)),
+ }),
dv: handle.dv,
};
}
@@ -83,9 +88,13 @@ function normalizeLeftHandle({
return undefined;
}
- const span = Math.max(1, rightKey.time - leftKey.time);
+ const span = mediaTime({
+ ticks: Math.max(1, rightKey.time - leftKey.time),
+ });
return {
- dt: Math.max(-span, Math.min(0, handle.dt)),
+ dt: mediaTime({
+ ticks: Math.max(-span, Math.min(0, handle.dt)),
+ }),
dv: handle.dv,
};
}
diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts
index ff0cef8f..70f9b5db 100644
--- a/apps/web/src/animation/keyframes.ts
+++ b/apps/web/src/animation/keyframes.ts
@@ -4,6 +4,7 @@ import type {
AnimationChannel,
AnimationInterpolation,
AnimationPath,
+ AnimationPropertyPath,
AnimationValue,
DiscreteAnimationChannel,
DiscreteAnimationKey,
@@ -29,6 +30,16 @@ import {
getScalarSegmentInterpolation,
normalizeChannel,
} from "./interpolation";
+import {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+} from "./property-registry";
+import {
+ type MediaTime,
+ roundMediaTime,
+ subMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { generateUUID } from "@/utils/id";
function isNearlySameTime({
@@ -166,7 +177,7 @@ function createScalarKey({
previousKey,
}: {
id: string;
- time: number;
+ time: MediaTime;
value: number;
interpolation?: AnimationInterpolation;
previousKey?: ScalarAnimationKey;
@@ -190,7 +201,7 @@ function createDiscreteKey({
value,
}: {
id: string;
- time: number;
+ time: MediaTime;
value: string | boolean;
}): DiscreteAnimationKey {
return {
@@ -236,7 +247,7 @@ function getTargetKeyMetadata({
keyframeId,
}: {
channel: AnimationChannel | undefined;
- time: number;
+ time: MediaTime;
keyframeId?: string;
}) {
const normalizedChannel =
@@ -275,7 +286,7 @@ function upsertDiscreteChannelKey({
keyframeId,
}: {
channel: DiscreteAnimationChannel | undefined;
- time: number;
+ time: MediaTime;
value: string | boolean;
keyframeId?: string;
}): DiscreteAnimationChannel {
@@ -332,7 +343,7 @@ function upsertScalarChannelKey({
keyframeId,
}: {
channel: ScalarAnimationChannel | undefined;
- time: number;
+ time: MediaTime;
value: number;
interpolation?: AnimationInterpolation;
defaultInterpolation?: AnimationInterpolation;
@@ -437,7 +448,7 @@ export function upsertPathKeyframe({
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
- time: number;
+ time: MediaTime;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -522,6 +533,47 @@ export function upsertPathKeyframe({
});
}
+export function upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ time: MediaTime;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+ });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value: coercedValue,
+ interpolation,
+ keyframeId,
+ kind: propertyDefinition.kind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ coerceValue: ({ value: nextValue }) =>
+ coerceAnimationValueForProperty({
+ propertyPath,
+ value: nextValue,
+ }),
+ });
+}
+
export function upsertKeyframe({
channel,
time,
@@ -530,7 +582,7 @@ export function upsertKeyframe({
keyframeId,
}: {
channel: AnimationChannel | undefined;
- time: number;
+ time: MediaTime;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -596,7 +648,7 @@ export function retimeKeyframe({
}: {
channel: AnimationChannel | undefined;
keyframeId: string;
- time: number;
+ time: MediaTime;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
@@ -841,7 +893,7 @@ export function clampAnimationsToDuration({
duration,
}: {
animations: ElementAnimations | undefined;
- duration: number;
+ duration: MediaTime;
}): ElementAnimations | undefined {
if (!animations || duration <= 0) {
return undefined;
@@ -877,7 +929,7 @@ function splitDiscreteChannelAtTime({
shouldIncludeSplitBoundary,
}: {
channel: DiscreteAnimationChannel | undefined;
- splitTime: number;
+ splitTime: MediaTime;
leftBoundaryId: string;
rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean;
@@ -893,7 +945,10 @@ function splitDiscreteChannelAtTime({
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
let rightKeys = normalizedChannel.keys
.filter((key) => key.time >= splitTime)
- .map((key) => ({ ...key, time: key.time - splitTime }));
+ .map((key) => ({
+ ...key,
+ time: subMediaTime({ a: key.time, b: splitTime }),
+ }));
if (shouldIncludeSplitBoundary) {
const hasBoundaryOnLeft = leftKeys.some((key) =>
@@ -921,7 +976,7 @@ function splitDiscreteChannelAtTime({
rightKeys = [
createDiscreteKey({
id: rightBoundaryId,
- time: 0,
+ time: ZERO_MEDIA_TIME,
value: boundaryValue as string | boolean,
}),
...rightKeys,
@@ -947,7 +1002,7 @@ function splitScalarChannelAtTime({
shouldIncludeSplitBoundary,
}: {
channel: ScalarAnimationChannel | undefined;
- splitTime: number;
+ splitTime: MediaTime;
leftBoundaryId: string;
rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean;
@@ -963,7 +1018,10 @@ function splitScalarChannelAtTime({
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
let rightKeys = normalizedChannel.keys
.filter((key) => key.time >= splitTime)
- .map((key) => ({ ...key, time: key.time - splitTime }));
+ .map((key) => ({
+ ...key,
+ time: subMediaTime({ a: key.time, b: splitTime }),
+ }));
const hasBoundaryOnLeft = leftKeys.some((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
@@ -1043,7 +1101,7 @@ function splitScalarChannelAtTime({
{
...leftKey,
rightHandle: {
- dt: q0.x - p0.x,
+ dt: roundMediaTime({ time: q0.x - p0.x }),
dv: q0.y - p0.y,
},
},
@@ -1052,7 +1110,7 @@ function splitScalarChannelAtTime({
time: splitTime,
value: boundaryValue,
leftHandle: {
- dt: r0.x - splitPoint.x,
+ dt: roundMediaTime({ time: r0.x - splitPoint.x }),
dv: r0.y - splitPoint.y,
},
segmentToNext: leftKey.segmentToNext,
@@ -1062,10 +1120,10 @@ function splitScalarChannelAtTime({
rightKeys = [
{
id: rightBoundaryId,
- time: 0,
+ time: ZERO_MEDIA_TIME,
value: boundaryValue,
rightHandle: {
- dt: r1.x - splitPoint.x,
+ dt: roundMediaTime({ time: r1.x - splitPoint.x }),
dv: r1.y - splitPoint.y,
},
segmentToNext: "bezier",
@@ -1073,9 +1131,9 @@ function splitScalarChannelAtTime({
},
{
...rightKey,
- time: rightKey.time - splitTime,
+ time: subMediaTime({ a: rightKey.time, b: splitTime }),
leftHandle: {
- dt: q2.x - p3.x,
+ dt: roundMediaTime({ time: q2.x - p3.x }),
dv: q2.y - p3.y,
},
},
@@ -1083,7 +1141,7 @@ function splitScalarChannelAtTime({
.filter((key) => key.time > rightKey.time)
.map((key) => ({
...key,
- time: key.time - splitTime,
+ time: subMediaTime({ a: key.time, b: splitTime }),
})),
];
} else {
@@ -1099,7 +1157,7 @@ function splitScalarChannelAtTime({
rightKeys = [
createScalarKey({
id: rightBoundaryId,
- time: 0,
+ time: ZERO_MEDIA_TIME,
value: boundaryValue,
interpolation: getScalarSegmentInterpolation({
segment: leftKey.segmentToNext,
@@ -1155,7 +1213,7 @@ export function splitAnimationsAtTime({
shouldIncludeSplitBoundary = true,
}: {
animations: ElementAnimations | undefined;
- splitTime: number;
+ splitTime: MediaTime;
shouldIncludeSplitBoundary?: boolean;
}): {
leftAnimations: ElementAnimations | undefined;
@@ -1271,7 +1329,7 @@ export function retimeElementKeyframe({
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
- time: number;
+ time: MediaTime;
}): ElementAnimations | undefined {
const binding = getBinding({ animations, propertyPath });
if (!binding) {
diff --git a/apps/web/src/animation/property-registry.ts b/apps/web/src/animation/property-registry.ts
new file mode 100644
index 00000000..aacb9972
--- /dev/null
+++ b/apps/web/src/animation/property-registry.ts
@@ -0,0 +1,390 @@
+import type {
+ AnimationBindingKind,
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+} from "@/animation/types";
+import { parseColorToLinearRgba } from "./binding-values";
+import type { TimelineElement } from "@/timeline";
+import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+} from "@/text/background";
+import {
+ canElementHaveAudio,
+ isVisualElement,
+} from "@/timeline/element-utils";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { DEFAULTS } from "@/timeline/defaults";
+import { snapToStep } from "@/utils/math";
+
+export interface NumericSpec {
+ min?: number;
+ max?: number;
+ step?: number;
+}
+
+export interface AnimationPropertyDefinition {
+ kind: AnimationBindingKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRanges?: Partial<Record<string, NumericSpec>>;
+ supportsElement: ({ element }: { element: TimelineElement }) => boolean;
+ getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
+ coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: AnimationValue;
+ }) => TimelineElement;
+}
+
+function applyNumericSpec({
+ value,
+ numericRange,
+}: {
+ value: number;
+ numericRange: NumericSpec | undefined;
+}): number {
+ if (!numericRange) {
+ return value;
+ }
+
+ const steppedValue =
+ numericRange.step != null
+ ? snapToStep({ value, step: numericRange.step })
+ : value;
+ const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
+ const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+}
+
+function coerceNumberValue({
+ value,
+ numericRange,
+}: {
+ value: AnimationValue;
+ numericRange?: NumericSpec;
+}): number | null {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return applyNumericSpec({ value, numericRange });
+}
+
+function coerceColorValue({
+ value,
+}: {
+ value: AnimationValue;
+}): string | null {
+ return typeof value === "string" && parseColorToLinearRgba({ color: value })
+ ? value
+ : null;
+}
+
+function createNumberPropertyDefinition({
+ numericRange,
+ supportsElement,
+ getValue,
+ setValue,
+}: {
+ numericRange?: NumericSpec;
+ supportsElement: AnimationPropertyDefinition["supportsElement"];
+ getValue: AnimationPropertyDefinition["getValue"];
+ setValue: AnimationPropertyDefinition["setValue"];
+}): AnimationPropertyDefinition {
+ return {
+ kind: "number",
+ defaultInterpolation: "linear",
+ numericRanges: numericRange ? { value: numericRange } : undefined,
+ supportsElement,
+ getValue,
+ coerceValue: ({ value }) =>
+ coerceNumberValue({
+ value,
+ numericRange,
+ }),
+ setValue,
+ };
+}
+
+const ANIMATION_PROPERTY_REGISTRY: Record<
+ AnimationPropertyPath,
+ AnimationPropertyDefinition
+> = {
+ "transform.positionX": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position.x : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, x: value as number },
+ },
+ }
+ : element,
+ }),
+ "transform.positionY": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position.y : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, y: value as number },
+ },
+ }
+ : element,
+ }),
+ "transform.scaleX": createNumberPropertyDefinition({
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleX : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scaleX: value as number },
+ }
+ : element,
+ }),
+ "transform.scaleY": createNumberPropertyDefinition({
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleY : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scaleY: value as number },
+ }
+ : element,
+ }),
+ "transform.rotate": createNumberPropertyDefinition({
+ numericRange: { min: -360, max: 360, step: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.rotate : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, rotate: value as number },
+ }
+ : element,
+ }),
+ opacity: createNumberPropertyDefinition({
+ numericRange: { min: 0, max: 1, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.opacity : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? { ...element, opacity: value as number }
+ : element,
+ }),
+ volume: createNumberPropertyDefinition({
+ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
+ supportsElement: ({ element }) => canElementHaveAudio(element),
+ getValue: ({ element }) =>
+ canElementHaveAudio(element) ? element.volume ?? 0 : null,
+ setValue: ({ element, value }) =>
+ canElementHaveAudio(element)
+ ? { ...element, volume: value as number }
+ : element,
+ }),
+ color: {
+ kind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) => (element.type === "text" ? element.color : null),
+ coerceValue: ({ value }) => coerceColorValue({ value }),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? { ...element, color: value as string }
+ : element,
+ },
+ "background.color": {
+ kind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text" ? element.background.color : null,
+ coerceValue: ({ value }) => coerceColorValue({ value }),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, color: value as string },
+ }
+ : element,
+ },
+ "background.paddingX": createNumberPropertyDefinition({
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingX: value as number },
+ }
+ : element,
+ }),
+ "background.paddingY": createNumberPropertyDefinition({
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingY: value as number },
+ }
+ : element,
+ }),
+ "background.offsetX": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetX: value as number },
+ }
+ : element,
+ }),
+ "background.offsetY": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetY: value as number },
+ }
+ : element,
+ }),
+ "background.cornerRadius": createNumberPropertyDefinition({
+ numericRange: {
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ step: 1,
+ },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, cornerRadius: value as number },
+ }
+ : element,
+ }),
+};
+
+export function isAnimationPropertyPath(
+ propertyPath: string,
+): propertyPath is AnimationPropertyPath {
+ return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
+}
+
+export function getAnimationPropertyDefinition({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationPropertyDefinition {
+ return ANIMATION_PROPERTY_REGISTRY[propertyPath];
+}
+
+export function supportsAnimationProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): boolean {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.supportsElement({ element });
+}
+
+export function getElementBaseValueForProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): AnimationValue | null {
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ if (!definition.supportsElement({ element })) {
+ return null;
+ }
+ return definition.getValue({ element });
+}
+
+export function withElementBaseValueForProperty({
+ element,
+ propertyPath,
+ value,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): TimelineElement {
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ const coercedValue = definition.coerceValue({ value });
+ if (coercedValue === null || !definition.supportsElement({ element })) {
+ return element;
+ }
+ return definition.setValue({ element, value: coercedValue });
+}
+
+export function getDefaultInterpolationForProperty({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationInterpolation {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.defaultInterpolation;
+}
+
+export function coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+}: {
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): AnimationValue | null {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.coerceValue({ value });
+}
diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts
index 7e241994..cfecfada 100644
--- a/apps/web/src/animation/types.ts
+++ b/apps/web/src/animation/types.ts
@@ -1,3 +1,6 @@
+import type { ParamValues } from "@/params";
+import type { MediaTime } from "@/wasm";
+
export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX",
"transform.positionY",
@@ -81,13 +84,13 @@ export type TangentMode = "auto" | "aligned" | "broken" | "flat";
export type ChannelExtrapolationMode = "hold" | "linear";
export interface CurveHandle {
- dt: number;
+ dt: MediaTime;
dv: number;
}
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
id: string;
- time: number; // relative to element start time
+ time: MediaTime; // relative to element start time
value: TValue;
}
@@ -213,7 +216,7 @@ export interface ScalarCurveKeyframePatch {
export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
- time: number;
+ time: MediaTime;
value: AnimationValue;
interpolation: AnimationInterpolation;
}
diff --git a/apps/web/src/clipboard/handlers/keyframes.ts b/apps/web/src/clipboard/handlers/keyframes.ts
index ad156e12..cb0c0e65 100644
--- a/apps/web/src/clipboard/handlers/keyframes.ts
+++ b/apps/web/src/clipboard/handlers/keyframes.ts
@@ -7,6 +7,7 @@ import type {
KeyframeClipboardCurvePatch,
KeyframeClipboardItem,
} from "../types";
+import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm";
function resolveSingleSourceElement({
selectedKeyframes,
@@ -80,7 +81,7 @@ function buildClipboardItem({
element: TimelineElement;
propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string;
-}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
+}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
const keyframe = getKeyframeById({
animations: element.animations,
propertyPath,
@@ -136,10 +137,11 @@ export const KeyframesClipboardHandler = {
}
const minTime = Math.min(...rawItems.map((item) => item.time));
+ const minTimeMedia = roundMediaTime({ time: minTime });
const items = rawItems
.map(({ time, ...item }) => ({
...item,
- timeOffset: time - minTime,
+ timeOffset: subMediaTime({ a: time, b: minTimeMedia }),
}))
.sort(
(left, right) =>
diff --git a/apps/web/src/clipboard/types.ts b/apps/web/src/clipboard/types.ts
index 0d249c9d..562f9ac7 100644
--- a/apps/web/src/clipboard/types.ts
+++ b/apps/web/src/clipboard/types.ts
@@ -12,6 +12,7 @@ import type {
ElementRef,
TrackType,
} from "@/timeline";
+import type { MediaTime } from "@/wasm";
export interface ElementClipboardItem {
trackId: string;
@@ -26,7 +27,7 @@ export interface KeyframeClipboardCurvePatch {
export interface KeyframeClipboardItem {
propertyPath: AnimationPath;
- timeOffset: number;
+ timeOffset: MediaTime;
value: AnimationValue;
interpolation: AnimationInterpolation;
curvePatches: KeyframeClipboardCurvePatch[];
@@ -61,7 +62,7 @@ export interface PasteContext {
editor: EditorCore;
selectedElements: ElementRef[];
selectedKeyframes: SelectedKeyframeRef[];
- time: number;
+ time: MediaTime;
}
export interface ClipboardHandler<TType extends ClipboardEntryType> {
diff --git a/apps/web/src/commands/scene/move-bookmark.ts b/apps/web/src/commands/scene/move-bookmark.ts
index a1624eb0..f3628b28 100644
--- a/apps/web/src/commands/scene/move-bookmark.ts
+++ b/apps/web/src/commands/scene/move-bookmark.ts
@@ -3,13 +3,14 @@ import { EditorCore } from "@/core";
import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/timeline/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
+import type { MediaTime } from "@/wasm";
export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
constructor(
- private fromTime: number,
- private toTime: number,
+ private fromTime: MediaTime,
+ private toTime: MediaTime,
) {
super();
}
diff --git a/apps/web/src/commands/scene/remove-bookmark.ts b/apps/web/src/commands/scene/remove-bookmark.ts
index ea592556..bff5c035 100644
--- a/apps/web/src/commands/scene/remove-bookmark.ts
+++ b/apps/web/src/commands/scene/remove-bookmark.ts
@@ -6,12 +6,13 @@ import {
getFrameTime,
removeBookmarkFromArray,
} from "@/timeline/bookmarks/index";
+import type { MediaTime } from "@/wasm";
export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- private frameTime: number = 0;
+ private frameTime: MediaTime = 0 as MediaTime;
- constructor(private time: number) {
+ constructor(private time: MediaTime) {
super();
}
diff --git a/apps/web/src/commands/scene/toggle-bookmark.ts b/apps/web/src/commands/scene/toggle-bookmark.ts
index 4193c76e..9f318f80 100644
--- a/apps/web/src/commands/scene/toggle-bookmark.ts
+++ b/apps/web/src/commands/scene/toggle-bookmark.ts
@@ -6,12 +6,13 @@ import {
getFrameTime,
toggleBookmarkInArray,
} from "@/timeline/bookmarks/index";
+import type { MediaTime } from "@/wasm";
export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- private frameTime: number = 0;
+ private frameTime: MediaTime = 0 as MediaTime;
- constructor(private time: number) {
+ constructor(private time: MediaTime) {
super();
}
diff --git a/apps/web/src/commands/scene/update-bookmark.ts b/apps/web/src/commands/scene/update-bookmark.ts
index 0d6a0193..238bb3de 100644
--- a/apps/web/src/commands/scene/update-bookmark.ts
+++ b/apps/web/src/commands/scene/update-bookmark.ts
@@ -6,12 +6,13 @@ import {
getFrameTime,
updateBookmarkInArray,
} from "@/timeline/bookmarks/index";
+import type { MediaTime } from "@/wasm";
export class UpdateBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
constructor(
- private time: number,
+ private time: MediaTime,
private updates: Partial<Omit<Bookmark, "time">>,
) {
super();
diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
index c19bd526..8d970f4d 100644
--- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
+++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
@@ -10,6 +10,13 @@ import type { SceneTracks, TimelineElement } from "@/timeline";
import { updateElementInSceneTracks } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import { generateUUID } from "@/utils/id";
+import {
+ addMediaTime,
+ type MediaTime,
+ maxMediaTime,
+ minMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
function pasteKeyframesIntoElement({
element,
@@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({
clipboardItems,
}: {
element: TimelineElement;
- time: number;
+ time: MediaTime;
clipboardItems: KeyframeClipboardItem[];
}): TimelineElement {
let nextElement = element;
@@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({
continue;
}
- const keyframeTime = Math.max(
- 0,
- Math.min(time + item.timeOffset, nextElement.duration),
- );
+ const keyframeTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: minMediaTime({
+ a: addMediaTime({ a: time, b: item.timeOffset }),
+ b: nextElement.duration,
+ }),
+ });
const nextAnimations = upsertPathKeyframe({
animations: nextElement.animations,
propertyPath: item.propertyPath,
@@ -79,7 +89,7 @@ export class PasteKeyframesCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly time: number;
+ private readonly time: MediaTime;
private readonly clipboardItems: KeyframeClipboardItem[];
constructor({
@@ -90,7 +100,7 @@ export class PasteKeyframesCommand extends Command {
}: {
trackId: string;
elementId: string;
- time: number;
+ time: MediaTime;
clipboardItems: KeyframeClipboardItem[];
}) {
super();
diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts
index 4523566e..be0c2b91 100644
--- a/apps/web/src/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/commands/timeline/clipboard/paste.ts
@@ -13,18 +13,25 @@ import {
enforceMainTrackStart,
} from "@/timeline/placement";
import { cloneAnimations } from "@/animation";
+import {
+ addMediaTime,
+ type MediaTime,
+ maxMediaTime,
+ subMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
export class PasteCommand extends Command {
private savedState: SceneTracks | null = null;
private pastedElements: { trackId: string; elementId: string }[] = [];
- private readonly time: number;
+ private readonly time: MediaTime;
private readonly clipboardItems: ElementClipboardItem[];
constructor({
time,
clipboardItems,
}: {
- time: number;
+ time: MediaTime;
clipboardItems: ElementClipboardItem[];
}) {
super();
@@ -39,8 +46,12 @@ export class PasteCommand extends Command {
this.savedState = editor.scenes.getActiveScene().tracks;
this.pastedElements = [];
- const minStart = Math.min(
- ...this.clipboardItems.map((item) => item.element.startTime),
+ const minStart = this.clipboardItems.reduce(
+ (earliestStartTime, item) =>
+ item.element.startTime < earliestStartTime
+ ? item.element.startTime
+ : earliestStartTime,
+ this.clipboardItems[0].element.startTime,
);
let updatedTracks = this.savedState;
@@ -97,12 +108,18 @@ export class PasteCommand extends Command {
targetTrackId: targetTrack.id,
requestedStartTime: earliestElement.startTime,
});
- const delta = adjustedEarliestStartTime - earliestElement.startTime;
+ const delta = subMediaTime({
+ a: adjustedEarliestStartTime,
+ b: earliestElement.startTime,
+ });
- if (delta !== 0) {
+ if (delta !== ZERO_MEDIA_TIME) {
elementsForPlacement = elementsToAdd.map((element) => ({
...element,
- startTime: Math.max(0, element.startTime + delta),
+ startTime: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: addMediaTime({ a: element.startTime, b: delta }),
+ }),
}));
}
}
@@ -168,14 +185,20 @@ function buildPastedElements({
time,
}: {
items: ElementClipboardItem[];
- minStart: number;
- time: number;
+ minStart: MediaTime;
+ time: MediaTime;
}): TimelineElement[] {
const elementsToAdd: TimelineElement[] = [];
for (const item of items) {
- const relativeOffset = item.element.startTime - minStart;
- const startTime = Math.max(0, time + relativeOffset);
+ const relativeOffset = subMediaTime({
+ a: item.element.startTime,
+ b: minStart,
+ });
+ const startTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: addMediaTime({ a: time, b: relativeOffset }),
+ });
const newElementId = generateUUID();
elementsToAdd.push({
diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts
index 8f65f7c7..2b1ab4ea 100644
--- a/apps/web/src/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts
@@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
import { cloneAnimations } from "@/animation";
+import type { MediaTime } from "@/wasm";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
@@ -119,7 +120,7 @@ function buildDuplicateElement({
}: {
element: TimelineElement;
id: string;
- startTime: number;
+ startTime: MediaTime;
}): TimelineElement {
return {
...element,
diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts
index 49ac6f2d..2110d67b 100644
--- a/apps/web/src/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/commands/timeline/element/insert-element.ts
@@ -18,6 +18,7 @@ import {
resolveTrackPlacement,
validateElementTrackCompatibility,
} from "@/timeline/placement";
+import { roundMediaTime } from "@/wasm";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -266,7 +267,12 @@ export class InsertElementCommand extends Command {
placementResult.kind === "existingTrack"
? {
...element,
- startTime: placementResult.adjustedStartTime ?? element.startTime,
+ startTime:
+ placementResult.adjustedStartTime !== undefined
+ ? roundMediaTime({
+ time: placementResult.adjustedStartTime,
+ })
+ : element.startTime,
}
: element;
diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
index aa04e2bc..94054372 100644
--- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -4,6 +4,12 @@ import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
+import {
+ type MediaTime,
+ maxMediaTime,
+ minMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
export class RetimeKeyframeCommand extends Command {
@@ -12,7 +18,7 @@ export class RetimeKeyframeCommand extends Command {
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
- private readonly nextTime: number;
+ private readonly nextTime: MediaTime;
constructor({
trackId,
@@ -25,7 +31,7 @@ export class RetimeKeyframeCommand extends Command {
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
- nextTime: number;
+ nextTime: MediaTime;
}) {
super();
this.trackId = trackId;
@@ -48,7 +54,10 @@ export class RetimeKeyframeCommand extends Command {
return element;
}
- const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
+ const boundedTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: minMediaTime({ a: this.nextTime, b: element.duration }),
+ });
return {
...element,
animations: retimeElementKeyframe({
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
index 7bd1b2e5..6eae0847 100644
--- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -9,6 +9,12 @@ import { isVisualElement } from "@/timeline/element-utils";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { AnimationInterpolation } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
+import {
+ type MediaTime,
+ maxMediaTime,
+ minMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
private readonly elementId: string;
private readonly effectId: string;
private readonly paramKey: string;
- private readonly time: number;
+ private readonly time: MediaTime;
private readonly value: number | string | boolean;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: string;
effectId: string;
paramKey: string;
- time: number;
+ time: MediaTime;
value: number | string | boolean;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
- const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ const boundedTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: minMediaTime({ a: this.time, b: element.duration }),
+ });
const propertyPath = buildEffectParamPath({
effectId: this.effectId,
paramKey: this.paramKey,
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
index 15c18bfc..e1b78f33 100644
--- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -9,13 +9,19 @@ import type {
AnimationInterpolation,
AnimationValue,
} from "@/animation/types";
+import {
+ type MediaTime,
+ maxMediaTime,
+ minMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
export class UpsertKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
- private readonly time: number;
+ private readonly time: MediaTime;
private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@@ -32,7 +38,7 @@ export class UpsertKeyframeCommand extends Command {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
- time: number;
+ time: MediaTime;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -64,7 +70,10 @@ export class UpsertKeyframeCommand extends Command {
return element;
}
- const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ const boundedTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: minMediaTime({ a: this.time, b: element.duration }),
+ });
return {
...element,
animations: upsertPathKeyframe({
diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts
index 11c317f4..0a551fc3 100644
--- a/apps/web/src/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/commands/timeline/element/split-elements.ts
@@ -9,12 +9,18 @@ import { EditorCore } from "@/core";
import { isRetimableElement } from "@/timeline";
import { splitAnimationsAtTime } from "@/animation";
import { getSourceSpanAtClipTime } from "@/retime";
+import {
+ addMediaTime,
+ type MediaTime,
+ roundMediaTime,
+ subMediaTime,
+} from "@/wasm";
export class SplitElementsCommand extends Command {
private savedState: SceneTracks | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[];
- private readonly splitTime: number;
+ private readonly splitTime: MediaTime;
private readonly retainSide: "both" | "left" | "right";
constructor({
@@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command {
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
- splitTime: number;
+ splitTime: MediaTime;
retainSide?: "both" | "left" | "right";
}) {
super();
@@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command {
return [element];
}
- const relativeTime = this.splitTime - element.startTime;
+ const relativeTime = subMediaTime({
+ a: this.splitTime,
+ b: element.startTime,
+ });
const leftVisibleDuration = relativeTime;
- const rightVisibleDuration = element.duration - relativeTime;
+ const rightVisibleDuration = subMediaTime({
+ a: element.duration,
+ b: relativeTime,
+ });
const retimeRef = isRetimableElement(element)
? element.retime
: undefined;
- const leftSourceSpan = getSourceSpanAtClipTime({
- clipTime: leftVisibleDuration,
- retime: retimeRef,
+ // Snap the source-side split point exactly once and derive the right
+ // half from it. Independently rounding both spans (left and total)
+ // would let a 1-tick rounding error desynchronise them, breaking the
+ // invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`.
+ // See the same discipline in `compute-resize.ts` (snap-once comment).
+ const leftSourceSpan = roundMediaTime({
+ time: getSourceSpanAtClipTime({
+ clipTime: leftVisibleDuration,
+ retime: retimeRef,
+ }),
+ });
+ const totalSourceSpan = roundMediaTime({
+ time: getSourceSpanAtClipTime({
+ clipTime: element.duration,
+ retime: retimeRef,
+ }),
});
- const totalSourceSpan = getSourceSpanAtClipTime({
- clipTime: element.duration,
- retime: retimeRef,
+ const rightSourceSpan = subMediaTime({
+ a: totalSourceSpan,
+ b: leftSourceSpan,
});
- const rightSourceSpan = totalSourceSpan - leftSourceSpan;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations,
splitTime: relativeTime,
@@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command {
});
let splitResult: TimelineElement[];
+ const leftTrimEnd = addMediaTime({
+ a: element.trimEnd,
+ b: rightSourceSpan,
+ });
+ const rightTrimStart = addMediaTime({
+ a: element.trimStart,
+ b: leftSourceSpan,
+ });
+
if (this.retainSide === "left") {
splitResult = [
{
...element,
duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightSourceSpan,
+ trimEnd: leftTrimEnd,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command {
id: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
- trimStart: element.trimStart + leftSourceSpan,
+ trimStart: rightTrimStart,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
} else {
- // "both" - split into two pieces
const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
@@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command {
{
...element,
duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightSourceSpan,
+ trimEnd: leftTrimEnd,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command {
id: secondElementId,
startTime: this.splitTime,
duration: rightVisibleDuration,
- trimStart: element.trimStart + leftSourceSpan,
+ trimStart: rightTrimStart,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx
index 276cb490..aaae79a9 100644
--- a/apps/web/src/components/editable-timecode.tsx
+++ b/apps/web/src/components/editable-timecode.tsx
@@ -1,15 +1,24 @@
"use client";
import { useEffect, useRef, useState } from "react";
-import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
+import {
+ formatTimecode,
+ type FrameRate,
+ type TimeCodeFormat,
+} from "opencut-wasm";
import { cn } from "@/utils/ui";
+import {
+ parseMediaTimecode,
+ snapSeekMediaTime,
+ type MediaTime,
+} from "@/wasm";
interface EditableTimecodeProps {
- time: number;
- duration: number;
+ time: MediaTime;
+ duration: MediaTime;
format?: TimeCodeFormat;
fps: FrameRate;
- onTimeChange?: ({ time }: { time: number }) => void;
+ onTimeChange?: ({ time }: { time: MediaTime }) => void;
className?: string;
disabled?: boolean;
}
@@ -46,7 +55,11 @@ export function EditableTimecode({
};
const applyEdit = () => {
- const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
+ const parsedTime = parseMediaTimecode({
+ timeCode: inputValue,
+ format,
+ fps,
+ });
if (parsedTime == null) {
setHasError(true);
@@ -54,7 +67,7 @@ export function EditableTimecode({
}
const clampedTime = duration
- ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
+ ? snapSeekMediaTime({ time: parsedTime, duration, fps })
: parsedTime;
onTimeChange?.({ time: clampedTime });
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
index 7cf89fbe..8cfb99c3 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -1,240 +1,241 @@
-"use client";
-
-import { Plus } from "lucide-react";
-import { type ReactNode, useEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { AspectRatio } from "@/components/ui/aspect-ratio";
-import { Button } from "@/components/ui/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { useEditor } from "@/editor/use-editor";
-import type { TimelineDragData } from "@/timeline/drag";
-import { cn } from "@/utils/ui";
-
-export interface DraggableItemProps {
- name: string;
- preview: ReactNode;
- dragData: TimelineDragData;
- onDragStart?: ({ e }: { e: React.DragEvent }) => void;
- onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
- aspectRatio?: number;
- className?: string;
- containerClassName?: string;
- shouldShowPlusOnDrag?: boolean;
- shouldShowLabel?: boolean;
- isRounded?: boolean;
- variant?: "card" | "compact";
- isDraggable?: boolean;
-}
-
-export function DraggableItem({
- name,
- preview,
- dragData,
- onDragStart,
- onAddToTimeline,
- aspectRatio = 16 / 9,
- className = "",
- containerClassName,
- shouldShowPlusOnDrag = true,
- shouldShowLabel = true,
- isRounded = true,
- variant = "card",
- isDraggable = true,
-}: DraggableItemProps) {
- const [isDragging, setIsDragging] = useState(false);
- const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
- const dragRef = useRef<HTMLDivElement>(null);
- const editor = useEditor();
-
- const handleAddToTimeline = () => {
- onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
- };
-
- const emptyImg = new window.Image();
- emptyImg.src =
- "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
-
- useEffect(() => {
- if (!isDragging) return;
-
- const handleDragOver = (e: DragEvent) => {
- setDragPosition({ x: e.clientX, y: e.clientY });
- };
-
- document.addEventListener("dragover", handleDragOver);
-
- return () => {
- document.removeEventListener("dragover", handleDragOver);
- };
- }, [isDragging]);
-
- const handleDragStart = (event: React.DragEvent) => {
- event.dataTransfer.setDragImage(emptyImg, 0, 0);
-
- editor.timeline.dragSource.begin({
- dataTransfer: event.dataTransfer,
- dragData,
- });
-
- setDragPosition({ x: event.clientX, y: event.clientY });
- setIsDragging(true);
-
- onDragStart?.({ e: event });
- };
-
- const handleDragEnd = () => {
- setIsDragging(false);
- editor.timeline.dragSource.end();
- };
-
- return (
- <>
- {variant === "card" ? (
- <div
- ref={dragRef}
- className={cn("group relative", containerClassName ?? "w-28")}
- >
- <div
- className={cn(
- "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
- className,
- )}
- >
- <AspectRatio
- ratio={aspectRatio}
- className={cn(
- "bg-accent relative overflow-hidden",
- isRounded && "rounded-sm",
- isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
- )}
- draggable={isDraggable}
- onDragStart={isDraggable ? handleDragStart : undefined}
- onDragEnd={isDraggable ? handleDragEnd : undefined}
- >
- {preview}
- {!isDragging && (
- <PlusButton
- className="opacity-0 group-hover:opacity-100"
- onClick={handleAddToTimeline}
- />
- )}
- </AspectRatio>
- {shouldShowLabel && (
- <span
- className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
- title={name}
- >
- <span className="sr-only">{name}</span>
- <span aria-hidden="true">
- {name.length > 8
- ? `${name.slice(0, 16)}...${name.slice(-3)}`
- : name}
- </span>
- </span>
- )}
- </div>
- </div>
- ) : (
- <div
- ref={dragRef}
- className={cn("group relative w-full", containerClassName)}
- >
- <button
- type="button"
- className={cn(
- "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
- isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
- className,
- )}
- draggable={isDraggable}
- onDragStart={isDraggable ? handleDragStart : undefined}
- onDragEnd={isDraggable ? handleDragEnd : undefined}
- >
- <div className="size-6 shrink-0 overflow-hidden rounded-sm">
- {preview}
- </div>
- <span className="w-full flex-1 truncate text-sm text-left">
- {name}
- </span>
- </button>
- </div>
- )}
-
- {isDraggable &&
- isDragging &&
- typeof document !== "undefined" &&
- createPortal(
- <div
- className="pointer-events-none fixed z-9999"
- style={{
- left: dragPosition.x - 40,
- top: dragPosition.y - 40,
- }}
- >
- <div className="w-[80px]">
- <AspectRatio
- ratio={1}
- className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
- >
- <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
- {preview}
- </div>
- {shouldShowPlusOnDrag && (
- <PlusButton
- onClick={handleAddToTimeline}
- tooltipText="Add to timeline or drag to position"
- />
- )}
- </AspectRatio>
- </div>
- </div>,
- document.body,
- )}
- </>
- );
-}
-
-function PlusButton({
- className,
- onClick,
- tooltipText,
-}: {
- className?: string;
- onClick?: () => void;
- tooltipText?: string;
-}) {
- const button = (
- <Button
- size="icon"
- className={cn(
- "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
- className,
- )}
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- onClick?.();
- }}
- title={tooltipText}
- >
- <Plus />
- </Button>
- );
-
- if (tooltipText) {
- return (
- <Tooltip>
- <TooltipTrigger asChild>{button}</TooltipTrigger>
- <TooltipContent>
- <p>{tooltipText}</p>
- </TooltipContent>
- </Tooltip>
- );
- }
-
- return button;
-}
+"use client";
+
+import { Plus } from "lucide-react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/editor/use-editor";
+import type { TimelineDragData } from "@/timeline/drag";
+import { cn } from "@/utils/ui";
+import type { MediaTime } from "@/wasm";
+
+export interface DraggableItemProps {
+ name: string;
+ preview: ReactNode;
+ dragData: TimelineDragData;
+ onDragStart?: ({ e }: { e: React.DragEvent }) => void;
+ onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
+ aspectRatio?: number;
+ className?: string;
+ containerClassName?: string;
+ shouldShowPlusOnDrag?: boolean;
+ shouldShowLabel?: boolean;
+ isRounded?: boolean;
+ variant?: "card" | "compact";
+ isDraggable?: boolean;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
+ };
+
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleDragOver = (e: DragEvent) => {
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ };
+ }, [isDragging]);
+
+ const handleDragStart = (event: React.DragEvent) => {
+ event.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ editor.timeline.dragSource.begin({
+ dataTransfer: event.dataTransfer,
+ dragData,
+ });
+
+ setDragPosition({ x: event.clientX, y: event.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e: event });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ editor.timeline.dragSource.end();
+ };
+
+ return (
+ <>
+ {variant === "card" ? (
+ <div
+ ref={dragRef}
+ className={cn("group relative", containerClassName ?? "w-28")}
+ >
+ <div
+ className={cn(
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
+ className,
+ )}
+ >
+ <AspectRatio
+ ratio={aspectRatio}
+ className={cn(
+ "bg-accent relative overflow-hidden",
+ isRounded && "rounded-sm",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ {preview}
+ {!isDragging && (
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
+ )}
+ </AspectRatio>
+ {shouldShowLabel && (
+ <span
+ className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
+ title={name}
+ >
+ <span className="sr-only">{name}</span>
+ <span aria-hidden="true">
+ {name.length > 8
+ ? `${name.slice(0, 16)}...${name.slice(-3)}`
+ : name}
+ </span>
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div
+ ref={dragRef}
+ className={cn("group relative w-full", containerClassName)}
+ >
+ <button
+ type="button"
+ className={cn(
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ className,
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
+ {preview}
+ </div>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
+ </button>
+ </div>
+ )}
+
+ {isDraggable &&
+ isDragging &&
+ typeof document !== "undefined" &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed z-9999"
+ style={{
+ left: dragPosition.x - 40,
+ top: dragPosition.y - 40,
+ }}
+ >
+ <div className="w-[80px]">
+ <AspectRatio
+ ratio={1}
+ className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
+ >
+ <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
+ {preview}
+ </div>
+ {shouldShowPlusOnDrag && (
+ <PlusButton
+ onClick={handleAddToTimeline}
+ tooltipText="Add to timeline or drag to position"
+ />
+ )}
+ </AspectRatio>
+ </div>
+ </div>,
+ document.body,
+ )}
+ </>
+ );
+}
+
+function PlusButton({
+ className,
+ onClick,
+ tooltipText,
+}: {
+ className?: string;
+ onClick?: () => void;
+ tooltipText?: string;
+}) {
+ const button = (
+ <Button
+ size="icon"
+ className={cn(
+ "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
+ className,
+ )}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
+ title={tooltipText}
+ >
+ <Plus />
+ </Button>
+ );
+
+ if (tooltipText) {
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return button;
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 4afd22b7..c071fdc7 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -26,7 +26,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
import { useEditor } from "@/editor/use-editor";
import { useFileUpload } from "@/media/use-file-upload";
import { invokeAction } from "@/actions";
@@ -255,11 +255,11 @@ function MediaAssetDraggable({
startTime,
}: {
asset: MediaAsset;
- startTime: number;
+ startTime: MediaTime;
}) => {
const duration =
asset.duration != null
- ? Math.round(asset.duration * TICKS_PER_SECOND)
+ ? mediaTimeFromSeconds({ seconds: asset.duration })
: DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: asset.id,
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
index 967156b3..83e31aec 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
@@ -1,22 +1,25 @@
import { useEditor } from "@/editor/use-editor";
import { getElementLocalTime } from "@/animation";
+import { addMediaTime, mediaTime, type MediaTime } from "@/wasm";
export function useElementPlayhead({
startTime,
duration,
}: {
- startTime: number;
- duration: number;
+ startTime: MediaTime;
+ duration: MediaTime;
}) {
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
- const localTime = getElementLocalTime({
- timelineTime: playheadTime,
- elementStartTime: startTime,
- elementDuration: duration,
+ const localTime = mediaTime({
+ ticks: getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: startTime,
+ elementDuration: duration,
+ }),
});
const isPlayheadWithinElementRange =
playheadTime >= startTime &&
- playheadTime <= startTime + duration;
+ playheadTime <= addMediaTime({ a: startTime, b: duration });
return { localTime, isPlayheadWithinElementRange };
}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
index 80fc171b..f6216208 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -1,95 +1,96 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import { upsertElementKeyframe } from "@/timeline/animation-properties";
-
-export function useKeyframedColorProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor,
- buildBaseUpdates,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedColor: string;
- buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
-}) {
- const editor = useEditor();
-
- const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const onChange = ({ color }: { color: string }) => {
- if (shouldUseAnimatedChannel) {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- animations: upsertElementKeyframe({
- animations,
- propertyPath,
- time: localTime,
- value: color,
- }),
- },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
- });
- };
-
- const onChangeEnd = () => editor.timeline.commitPreview();
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
- ],
- });
- };
-
- return {
- isKeyframedAtTime,
- hasAnimatedKeyframes,
- keyframeIdAtTime,
- onChange,
- onChangeEnd,
- toggleKeyframe,
- };
-}
+import { useEditor } from "@/editor/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
+import type { MediaTime } from "@/wasm";
+
+export function useKeyframedColorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: MediaTime;
+ isPlayheadWithinElementRange: boolean;
+ resolvedColor: string;
+ buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const onChange = ({ color }: { color: string }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: color,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
+ });
+ };
+
+ const onChangeEnd = () => editor.timeline.commitPreview();
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
+ ],
+ });
+ };
+
+ return {
+ isKeyframedAtTime,
+ hasAnimatedKeyframes,
+ keyframeIdAtTime,
+ onChange,
+ onChangeEnd,
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
index 32222b7a..d52f7d97 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -1,174 +1,175 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import { upsertElementKeyframe } from "@/timeline/animation-properties";
-import { snapToStep } from "@/utils/math";
-import { usePropertyDraft } from "./use-property-draft";
-
-export function useKeyframedNumberProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- displayValue,
- parse,
- valueAtPlayhead,
- step,
- buildBaseUpdates,
- buildAdditionalKeyframes,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- displayValue: string;
- parse: (input: string) => number | null;
- valueAtPlayhead: number;
- step?: number;
- buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
- buildAdditionalKeyframes?: ({
- value,
- }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
-}) {
- const editor = useEditor();
- const snapValue = (value: number) =>
- step != null ? snapToStep({ value, step }) : value;
-
- const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const previewValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- const updatedAnimations = [
- { propertyPath, value: nextValue },
- ...additionalKeyframes,
- ].reduce(
- (currentAnimations, keyframe) =>
- upsertElementKeyframe({
- animations: currentAnimations,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- }),
- animations,
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: { animations: updatedAnimations },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- const propertyDraft = usePropertyDraft({
- displayValue,
- parse: (input) => {
- const parsedValue = parse(input);
- return parsedValue === null ? null : snapValue(parsedValue);
- },
- onPreview: (value) => previewValue({ value }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- keyframeId: keyframeIdAtTime,
- },
- ],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- time: localTime,
- value: snapValue(valueAtPlayhead),
- },
- ],
- });
- };
-
- const commitValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: nextValue },
- ...additionalKeyframes.map((keyframe) => ({
- trackId,
- elementId,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- })),
- ],
- });
- return;
- }
-
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId,
- patch: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- return {
- ...propertyDraft,
- hasAnimatedKeyframes,
- isKeyframedAtTime,
- keyframeIdAtTime,
- toggleKeyframe,
- commitValue,
- };
-}
+import { useEditor } from "@/editor/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+import type { MediaTime } from "@/wasm";
+
+export function useKeyframedNumberProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue,
+ parse,
+ valueAtPlayhead,
+ step,
+ buildBaseUpdates,
+ buildAdditionalKeyframes,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: MediaTime;
+ isPlayheadWithinElementRange: boolean;
+ displayValue: string;
+ parse: (input: string) => number | null;
+ valueAtPlayhead: number;
+ step?: number;
+ buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
+ buildAdditionalKeyframes?: ({
+ value,
+ }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
+}) {
+ const editor = useEditor();
+ const snapValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
+ if (shouldUseAnimatedChannel) {
+ const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
+ const updatedAnimations = [
+ { propertyPath, value: nextValue },
+ ...additionalKeyframes,
+ ].reduce(
+ (currentAnimations, keyframe) =>
+ upsertElementKeyframe({
+ animations: currentAnimations,
+ propertyPath: keyframe.propertyPath,
+ time: localTime,
+ value: keyframe.value,
+ }),
+ animations,
+ );
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: { animations: updatedAnimations },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value: nextValue }),
+ },
+ ],
+ });
+ };
+
+ const propertyDraft = usePropertyDraft({
+ displayValue,
+ parse: (input) => {
+ const parsedValue = parse(input);
+ return parsedValue === null ? null : snapValue(parsedValue);
+ },
+ onPreview: (value) => previewValue({ value }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: snapValue(valueAtPlayhead),
+ },
+ ],
+ });
+ };
+
+ const commitValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
+ if (shouldUseAnimatedChannel) {
+ const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: nextValue },
+ ...additionalKeyframes.map((keyframe) => ({
+ trackId,
+ elementId,
+ propertyPath: keyframe.propertyPath,
+ time: localTime,
+ value: keyframe.value,
+ })),
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ patch: buildBaseUpdates({ value: nextValue }),
+ },
+ ],
+ });
+ };
+
+ return {
+ ...propertyDraft,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitValue,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
index 18e027c7..4f4b9cf8 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -17,6 +17,7 @@ import {
} from "@/animation/animated-params";
import type { ParamDefinition } from "@/params";
import type { TimelineElement } from "@/timeline";
+import type { MediaTime } from "@/wasm";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
@@ -41,7 +42,7 @@ export function useKeyframedParamProperty({
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
- localTime: number;
+ localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
buildBaseUpdates: ({
diff --git a/apps/web/src/core/managers/clipboard-manager.ts b/apps/web/src/core/managers/clipboard-manager.ts
index 8a2a51c9..283a78ae 100644
--- a/apps/web/src/core/managers/clipboard-manager.ts
+++ b/apps/web/src/core/managers/clipboard-manager.ts
@@ -6,6 +6,7 @@ import {
type CopyContext,
type PasteContext,
} from "@/clipboard";
+import type { MediaTime } from "@/wasm";
export class ClipboardManager {
private entry: ClipboardEntry | null = null;
@@ -34,7 +35,7 @@ export class ClipboardManager {
return true;
}
- paste({ time }: { time?: number } = {}): boolean {
+ paste({ time }: { time?: MediaTime } = {}): boolean {
if (!this.entry) {
return false;
}
@@ -64,7 +65,7 @@ export class ClipboardManager {
};
}
- private getPasteContext({ time }: { time?: number }): PasteContext {
+ private getPasteContext({ time }: { time?: MediaTime }): PasteContext {
return {
editor: this.editor,
selectedElements: this.editor.selection.getSelectedElements(),
diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts
index a243dc19..9b9d26bc 100644
--- a/apps/web/src/core/managers/playback-manager.ts
+++ b/apps/web/src/core/managers/playback-manager.ts
@@ -1,20 +1,26 @@
import type { EditorCore } from "@/core";
-import { TICKS_PER_SECOND } from "@/wasm";
-import { roundToFrame } from "opencut-wasm";
+import {
+ addMediaTime,
+ clampMediaTime,
+ type MediaTime,
+ mediaTimeFromSeconds,
+ roundFrameTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
export class PlaybackManager {
private isPlaying = false;
- private currentTime = 0;
+ private currentTime: MediaTime = ZERO_MEDIA_TIME;
private volume = 1;
private muted = false;
private previousVolume = 1;
private isScrubbing = false;
private listeners = new Set<() => void>();
- private updateListeners = new Set<(time: number) => void>();
- private seekListeners = new Set<(time: number) => void>();
+ private updateListeners = new Set<(time: MediaTime) => void>();
+ private seekListeners = new Set<(time: MediaTime) => void>();
private playbackTimer: number | null = null;
private playbackStartWallTime = 0;
- private playbackStartTime = 0;
+ private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
private timelineScopeBound = false;
constructor(private editor: EditorCore) {}
@@ -40,7 +46,7 @@ export class PlaybackManager {
}
if (this.currentTime >= maxTime) {
- this.seek({ time: 0 });
+ this.seek({ time: ZERO_MEDIA_TIME });
}
this.isPlaying = true;
@@ -62,7 +68,7 @@ export class PlaybackManager {
}
}
- seek({ time }: { time: number }): void {
+ seek({ time }: { time: MediaTime }): void {
this.currentTime = this.clampTimeToTimeline(time);
if (this.isPlaying) {
this.playbackStartWallTime = performance.now();
@@ -109,7 +115,7 @@ export class PlaybackManager {
return this.isPlaying;
}
- getCurrentTime(): number {
+ getCurrentTime(): MediaTime {
return this.currentTime;
}
@@ -135,12 +141,12 @@ export class PlaybackManager {
return () => this.listeners.delete(listener);
}
- onUpdate(listener: (time: number) => void): () => void {
+ onUpdate(listener: (time: MediaTime) => void): () => void {
this.updateListeners.add(listener);
return () => this.updateListeners.delete(listener);
}
- onSeek(listener: (time: number) => void): () => void {
+ onSeek(listener: (time: MediaTime) => void): () => void {
this.seekListeners.add(listener);
return () => this.seekListeners.delete(listener);
}
@@ -165,6 +171,7 @@ export class PlaybackManager {
if (timeChanged) {
this.notifySeek(this.currentTime);
+ this.dispatchSeekEvent(this.currentTime);
}
}
@@ -174,6 +181,18 @@ export class PlaybackManager {
});
}
+ private notifyUpdate(time: MediaTime): void {
+ this.updateListeners.forEach((fn) => {
+ fn(time);
+ });
+ }
+
+ private notifySeek(time: MediaTime): void {
+ this.seekListeners.forEach((fn) => {
+ fn(time);
+ });
+ }
+
private startTimer(): void {
if (this.playbackTimer) {
cancelAnimationFrame(this.playbackTimer);
@@ -197,40 +216,42 @@ export class PlaybackManager {
const fps = this.editor.project.getActive()?.settings.fps;
const elapsedSeconds =
(performance.now() - this.playbackStartWallTime) / 1000;
- const rawTime =
- this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
- const newTime = fps
- ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
- : rawTime;
+ const rawTime = addMediaTime({
+ a: this.playbackStartTime,
+ b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
+ });
+ const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime;
const maxTime = this.editor.timeline.getTotalDuration();
if (newTime >= maxTime) {
this.pause();
this.currentTime = maxTime;
this.notify();
- this.notifySeek(maxTime);
- return;
+ this.notifySeek(maxTime);
+ this.dispatchSeekEvent(maxTime);
+ return;
}
this.currentTime = newTime;
this.notifyUpdate(newTime);
+ this.dispatchUpdateEvent(newTime);
this.playbackTimer = requestAnimationFrame(this.updateTime);
};
- private clampTimeToTimeline(time: number): number {
+ private clampTimeToTimeline(time: MediaTime): MediaTime {
const maxTime = this.editor.timeline.getTotalDuration();
- return Math.max(0, Math.min(maxTime, time));
+ return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime });
}
- private notifySeek(time: number): void {
- for (const fn of this.seekListeners) {
- fn(time);
+ private dispatchSeekEvent(time: MediaTime): void {
+ if (typeof window === "undefined") {
+ return;
}
}
- private notifyUpdate(time: number): void {
- for (const fn of this.updateListeners) {
- fn(time);
+ private dispatchUpdateEvent(time: MediaTime): void {
+ if (typeof window === "undefined") {
+ return;
}
}
}
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts
index 13f251a1..c9558a6d 100644
--- a/apps/web/src/core/managers/scenes-manager.ts
+++ b/apps/web/src/core/managers/scenes-manager.ts
@@ -1,5 +1,5 @@
import type { EditorCore } from "@/core";
-import type { SceneTracks, TScene } from "@/timeline";
+import type { Bookmark, SceneTracks, TScene } from "@/timeline";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
@@ -21,6 +21,7 @@ import {
ToggleBookmarkCommand,
UpdateBookmarkCommand,
} from "@/commands/scene";
+import type { MediaTime } from "@/wasm";
export class ScenesManager {
private active: TScene | null = null;
@@ -106,12 +107,12 @@ export class ScenesManager {
this.notify();
}
- async toggleBookmark({ time }: { time: number }): Promise<void> {
+ async toggleBookmark({ time }: { time: MediaTime }): Promise<void> {
const command = new ToggleBookmarkCommand(time);
this.editor.command.execute({ command });
}
- isBookmarked({ time }: { time: number }): boolean {
+ isBookmarked({ time }: { time: MediaTime }): boolean {
const activeScene = this.getActiveScene();
const activeProject = this.editor.project.getActive();
@@ -125,7 +126,7 @@ export class ScenesManager {
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
}
- async removeBookmark({ time }: { time: number }): Promise<void> {
+ async removeBookmark({ time }: { time: MediaTime }): Promise<void> {
const command = new RemoveBookmarkCommand(time);
this.editor.command.execute({ command });
}
@@ -134,8 +135,8 @@ export class ScenesManager {
time,
updates,
}: {
- time: number;
- updates: Partial<{ note: string; color: string; duration: number }>;
+ time: MediaTime;
+ updates: Partial<Omit<Bookmark, "time">>;
}): Promise<void> {
const command = new UpdateBookmarkCommand(time, updates);
this.editor.command.execute({ command });
@@ -145,14 +146,14 @@ export class ScenesManager {
fromTime,
toTime,
}: {
- fromTime: number;
- toTime: number;
+ fromTime: MediaTime;
+ toTime: MediaTime;
}): Promise<void> {
const command = new MoveBookmarkCommand(fromTime, toTime);
this.editor.command.execute({ command });
}
- getBookmarkAtTime({ time }: { time: number }) {
+ getBookmarkAtTime({ time }: { time: MediaTime }) {
const activeScene = this.active;
const activeProject = this.editor.project.getActive();
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index a2bf7451..8fbfdb08 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -11,6 +11,7 @@ import type {
import { calculateTotalDuration } from "@/timeline";
import { TimelineDragSource } from "@/timeline/drag-source";
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
+import { lastFrameMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
import {
canElementBeHidden,
canElementHaveAudio,
@@ -27,7 +28,6 @@ import {
resolveAnimationPathValueAtTime,
} from "@/animation";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
-import { lastFrameTime } from "opencut-wasm";
import { BatchCommand } from "@/commands";
import {
AddTrackCommand,
@@ -97,10 +97,10 @@ export class TimelineManager {
pushHistory = true,
}: {
elementId: string;
- trimStart: number;
- trimEnd: number;
- startTime?: number;
- duration?: number;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ startTime?: MediaTime;
+ duration?: MediaTime;
pushHistory?: boolean;
}): void {
const trackId = this.findTrackIdForElement({ elementId });
@@ -190,7 +190,7 @@ export class TimelineManager {
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
- splitTime: number;
+ splitTime: MediaTime;
retainSide?: "both" | "left" | "right";
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand({
@@ -202,20 +202,20 @@ export class TimelineManager {
return command.getRightSideElements();
}
- getTotalDuration(): number {
+ getTotalDuration(): MediaTime {
const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
return calculateTotalDuration({ tracks: activeScene.tracks });
}
- getLastFrameTime(): number {
+ getLastFrameTime(): MediaTime {
const duration = this.getTotalDuration();
const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration;
- return lastFrameTime({ duration, rate: fps }) ?? duration;
+ return lastFrameMediaTime({ duration, fps });
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
@@ -485,7 +485,7 @@ export class TimelineManager {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
- time: number;
+ time: MediaTime;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -602,7 +602,7 @@ export class TimelineManager {
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
- time: number;
+ time: MediaTime;
}): void {
const command = new RetimeKeyframeCommand({
trackId,
@@ -660,7 +660,7 @@ export class TimelineManager {
elementId: string;
effectId: string;
paramKey: string;
- time: number;
+ time: MediaTime;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
@@ -709,9 +709,9 @@ export class TimelineManager {
updates,
}: {
updates: readonly {
- readonly trackId: string;
- readonly elementId: string;
- readonly updates: Partial<TimelineElement>;
+ trackId: string;
+ elementId: string;
+ updates: Partial<TimelineElement>;
}[];
}): void {
let changedOverlayCount = 0;
diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx
index e0ae0236..13cd7b84 100644
--- a/apps/web/src/graphics/components/graphic-tab.tsx
+++ b/apps/web/src/graphics/components/graphic-tab.tsx
@@ -1,241 +1,239 @@
-"use client";
-
-import { useRef } from "react";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import {
- useKeyframedParamProperty,
- type KeyframedParamPropertyResult,
-} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
-import type { ParamDefinition, ParamValues } from "@/params";
-import type { GraphicElement } from "@/timeline";
-import {
- graphicsRegistry,
- registerDefaultGraphics,
- resolveGraphicElementParamsAtTime,
-} from "@/graphics";
-import { useElementPreview } from "@/timeline/hooks/use-element-preview";
-import { useEditor } from "@/editor/use-editor";
-import {
- Section,
- SectionContent,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-
-registerDefaultGraphics();
-
-const DEFAULT_STROKE_WIDTH = 2;
-
-export function GraphicTab({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicElementParamsAtTime({
- element: liveElement,
- localTime,
- });
-
- const shapeParams = definition.params.filter((p) => p.group !== "stroke");
- const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
-
- return (
- <div className="flex flex-col">
- <Section collapsible sectionKey={`${element.id}:graphic`}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {shapeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
- </div>
- );
-}
-
-function StrokeSection({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicElementParamsAtTime({
- element: liveElement,
- localTime,
- });
- const strokeParams = definition.params.filter((p) => p.group === "stroke");
- const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
- const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
-
- const toggleStroke = () => {
- if (isStrokeEnabled) {
- lastStrokeWidth.current = Number(
- element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
- );
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { params: { ...element.params, strokeWidth: 0 } },
- },
- ],
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- params: {
- ...element.params,
- strokeWidth: lastStrokeWidth.current,
- },
- },
- },
- ],
- });
- }
- };
-
- return (
- <Section
- collapsible
- defaultOpen={isStrokeEnabled}
- sectionKey={`${element.id}:stroke`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleStroke();
- }}
- >
- <HugeiconsIcon
- icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Stroke</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
- >
- <SectionFields>
- {strokeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function AnimatedGraphicParamField({
- param,
- trackId,
- element,
- localTime,
- isPlayheadWithinElementRange,
- resolvedParams,
-}: {
- param: ParamDefinition;
- trackId: string;
- element: GraphicElement;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedParams: ParamValues;
-}) {
- const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
- {
- param,
- trackId,
- elementId: element.id,
- animations: element.animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedParams[param.key] ?? param.default,
- buildBaseUpdates: ({ value }) => ({
- params: {
- ...element.params,
- [param.key]: value,
- },
- }),
- },
- );
-
- return (
- <PropertyParamField
- param={param}
- value={resolvedParams[param.key] ?? param.default}
- onPreview={animatedParam.onPreview}
- onCommit={animatedParam.onCommit}
- keyframe={{
- isActive: animatedParam.isKeyframedAtTime,
- isDisabled: !isPlayheadWithinElementRange,
- onToggle: animatedParam.toggleKeyframe,
- }}
- />
- );
-}
+"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
+import type { ParamDefinition, ParamValues } from "@/params";
+import type { GraphicElement } from "@/timeline";
+import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics";
+import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+import { useEditor } from "@/editor/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import type { MediaTime } from "@/wasm";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicElementParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicElementParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ key?: string;
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: MediaTime;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
+ {
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ },
+ );
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/media/use-paste-media.ts b/apps/web/src/media/use-paste-media.ts
index 3055920e..387b2b9a 100644
--- a/apps/web/src/media/use-paste-media.ts
+++ b/apps/web/src/media/use-paste-media.ts
@@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media";
import { InsertElementCommand } from "@/commands/timeline";
import { BatchCommand } from "@/commands";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTimeFromSeconds } from "@/wasm";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/media/types";
@@ -75,7 +75,7 @@ export function usePasteMedia() {
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration != null
- ? Math.round(asset.duration * TICKS_PER_SECOND)
+ ? mediaTimeFromSeconds({ seconds: asset.duration })
: DEFAULT_NEW_ELEMENT_DURATION;
const trackType = asset.type === "audio" ? "audio" : "video";
diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx
index 783dd239..1a5ce1bc 100644
--- a/apps/web/src/preview/components/toolbar.tsx
+++ b/apps/web/src/preview/components/toolbar.tsx
@@ -22,6 +22,9 @@ import {
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
import { usePreviewViewport } from "./preview-viewport";
+import { GridPopover } from "./guide-popover";
+import { usePreviewStore } from "@/preview/preview-store";
+import type { MediaTime } from "@/wasm";
export function PreviewToolbar({
onToggleFullscreen,
@@ -60,7 +63,7 @@ function TimecodeDisplay() {
const editor = useEditor();
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
const fps = useEditor((e) => e.project.getActive().settings.fps);
- const [currentTime, setCurrentTime] = useState(() =>
+ const [currentTime, setCurrentTime] = useState<MediaTime>(() =>
editor.playback.getCurrentTime(),
);
diff --git a/apps/web/src/project/types.ts b/apps/web/src/project/types.ts
index 42e844f6..dca2d785 100644
--- a/apps/web/src/project/types.ts
+++ b/apps/web/src/project/types.ts
@@ -1,5 +1,6 @@
import type { FrameRate } from "opencut-wasm";
import type { TScene } from "@/timeline/types";
+import type { MediaTime } from "@/wasm";
export type TBackground =
| {
@@ -20,7 +21,7 @@ export interface TProjectMetadata {
id: string;
name: string;
thumbnail?: string;
- duration: number;
+ duration: MediaTime;
createdAt: Date;
updatedAt: Date;
}
@@ -37,7 +38,7 @@ export interface TProjectSettings {
export interface TTimelineViewState {
zoomLevel: number;
scrollLeft: number;
- playheadTime: number;
+ playheadTime: MediaTime;
}
export interface TProject {
diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx
index 48b1d0e5..52d2f051 100644
--- a/apps/web/src/rendering/components/blending-tab.tsx
+++ b/apps/web/src/rendering/components/blending-tab.tsx
@@ -30,14 +30,15 @@ import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/
import { resolveOpacityAtTime } from "@/animation/values";
import { DEFAULTS } from "@/timeline/defaults";
import { isPropertyAtDefault } from "./transform-tab";
+import type { MediaTime } from "@/wasm";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
- startTime: number;
- duration: number;
+ startTime: MediaTime;
+ duration: MediaTime;
animations?: ElementAnimations;
};
diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts
index 809f7103..36a6ecb5 100644
--- a/apps/web/src/services/renderer/resolve.ts
+++ b/apps/web/src/services/renderer/resolve.ts
@@ -1,4 +1,4 @@
-import { mediaTimeToSeconds } from "opencut-wasm";
+import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
import { getElementLocalTime } from "@/animation";
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
import {
@@ -204,7 +204,7 @@ async function resolveVideoNode({
const frame = await videoCache.getFrameAt({
mediaId: node.params.mediaId,
file: node.params.file,
- time: mediaTimeToSeconds({ time: sourceTimeTicks }),
+ time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
});
if (!frame) {
return null;
@@ -421,7 +421,7 @@ async function resolveBackdropSource({
const frame = await videoCache.getFrameAt({
mediaId: node.params.mediaId,
file: node.params.file,
- time: mediaTimeToSeconds({ time: sourceTimeTicks }),
+ time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
});
if (!frame) {
return null;
diff --git a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts
new file mode 100644
index 00000000..d467c8a1
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts
@@ -0,0 +1,257 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV27ToV28 } from "../transformers/v27-to-v28";
+
+describe("V27 to V28 Migration", () => {
+ test("rounds persisted media-time floats back to integer ticks", () => {
+ const result = transformProjectV27ToV28({
+ project: {
+ id: "project-v27-float-time",
+ version: 27,
+ metadata: {
+ id: "project-v27-float-time",
+ name: "Project",
+ duration: 2_152_466.3677130044,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ settings: {
+ fps: { numerator: 30, denominator: 1 },
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ timelineViewState: {
+ zoomLevel: 1.25,
+ scrollLeft: 120,
+ playheadTime: 301_234.8,
+ },
+ scenes: [
+ {
+ id: "scene-1",
+ name: "Scene 1",
+ isMain: true,
+ bookmarks: [
+ {
+ time: 120_000.4,
+ duration: 60_000.6,
+ note: "Marker",
+ color: "#ff0000",
+ },
+ ],
+ tracks: {
+ main: {
+ id: "main-track",
+ type: "video",
+ name: "Main",
+ muted: false,
+ hidden: false,
+ elements: [
+ {
+ id: "element-1",
+ type: "video",
+ name: "Clip",
+ startTime: 300_000.49,
+ duration: 2_152_466.3677130044,
+ trimStart: 30_000.2,
+ trimEnd: 15_000.7,
+ sourceDuration: 2_197_467.6,
+ mediaId: "media-1",
+ transform: {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ animations: {
+ channels: {
+ opacity: {
+ kind: "scalar",
+ keys: [
+ {
+ id: "key-1",
+ time: 1_000.6,
+ value: 1,
+ segmentToNext: "bezier",
+ tangentMode: "flat",
+ leftHandle: { dt: -120.6, dv: 0 },
+ rightHandle: { dt: 60.4, dv: 0 },
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(28);
+
+ const metadata = result.project.metadata as Record<string, unknown>;
+ expect(metadata.duration).toBe(2_152_466);
+
+ const timelineViewState = result.project.timelineViewState as Record<
+ string,
+ unknown
+ >;
+ expect(timelineViewState.playheadTime).toBe(301_235);
+ expect(timelineViewState.zoomLevel).toBe(1.25);
+ expect(timelineViewState.scrollLeft).toBe(120);
+
+ const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scene = scenes[0];
+ expect(scene.bookmarks).toEqual([
+ {
+ time: 120_000,
+ duration: 60_001,
+ note: "Marker",
+ color: "#ff0000",
+ },
+ ]);
+
+ const tracks = scene.tracks as Record<string, unknown>;
+ const mainTrack = tracks.main as Record<string, unknown>;
+ const elements = mainTrack.elements as Array<Record<string, unknown>>;
+ const element = elements[0];
+ expect(element.startTime).toBe(300_000);
+ expect(element.duration).toBe(2_152_466);
+ expect(element.trimStart).toBe(30_000);
+ expect(element.trimEnd).toBe(15_001);
+ expect(element.sourceDuration).toBe(2_197_468);
+
+ const animations = element.animations as Record<string, unknown>;
+ const channels = animations.channels as Record<string, Record<string, unknown>>;
+ const opacityChannel = channels.opacity;
+ expect(opacityChannel.keys).toEqual([
+ {
+ id: "key-1",
+ time: 1_001,
+ value: 1,
+ segmentToNext: "bezier",
+ tangentMode: "flat",
+ leftHandle: { dt: -121, dv: 0 },
+ rightHandle: { dt: 60, dv: 0 },
+ },
+ ]);
+ });
+
+ test("keeps already-integer media-time values unchanged", () => {
+ const result = transformProjectV27ToV28({
+ project: {
+ id: "project-v27-integer-time",
+ version: 27,
+ metadata: {
+ id: "project-v27-integer-time",
+ name: "Project",
+ duration: 120_000,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ timelineViewState: {
+ zoomLevel: 2,
+ scrollLeft: 300,
+ playheadTime: 30_000,
+ },
+ scenes: [
+ {
+ id: "scene-1",
+ bookmarks: [{ time: 60_000, duration: 15_000 }],
+ tracks: {
+ main: {
+ id: "main-track",
+ type: "video",
+ name: "Main",
+ muted: false,
+ hidden: false,
+ elements: [
+ {
+ id: "element-1",
+ type: "video",
+ name: "Clip",
+ startTime: 10_000,
+ duration: 20_000,
+ trimStart: 1_000,
+ trimEnd: 2_000,
+ sourceDuration: 23_000,
+ mediaId: "media-1",
+ transform: {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(28);
+
+ const metadata = result.project.metadata as Record<string, unknown>;
+ expect(metadata.duration).toBe(120_000);
+
+ const timelineViewState = result.project.timelineViewState as Record<
+ string,
+ unknown
+ >;
+ expect(timelineViewState).toEqual({
+ zoomLevel: 2,
+ scrollLeft: 300,
+ playheadTime: 30_000,
+ });
+
+ const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scene = scenes[0];
+ expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]);
+
+ const tracks = scene.tracks as Record<string, unknown>;
+ const mainTrack = tracks.main as Record<string, unknown>;
+ const element = (mainTrack.elements as Array<Record<string, unknown>>)[0];
+ expect(element.startTime).toBe(10_000);
+ expect(element.duration).toBe(20_000);
+ expect(element.trimStart).toBe(1_000);
+ expect(element.trimEnd).toBe(2_000);
+ expect(element.sourceDuration).toBe(23_000);
+ });
+
+ test("skips projects already on v28", () => {
+ const result = transformProjectV27ToV28({
+ project: {
+ id: "project-v28",
+ version: 28,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v28");
+ });
+
+ test("skips projects not on v27", () => {
+ const result = transformProjectV27ToV28({
+ project: {
+ id: "project-v26",
+ version: 26,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("not v27");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index b290b713..150da7c4 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -26,10 +26,11 @@ import { V23toV24Migration } from "./v23-to-v24";
import { V24toV25Migration } from "./v24-to-v25";
import { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27";
+import { V27toV28Migration } from "./v27-to-v28";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 27;
+export const CURRENT_PROJECT_VERSION = 28;
export const migrations = [
new V0toV1Migration(),
@@ -59,4 +60,5 @@ export const migrations = [
new V24toV25Migration(),
new V25toV26Migration(),
new V26toV27Migration(),
+ new V27toV28Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts
new file mode 100644
index 00000000..fc8d8432
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts
@@ -0,0 +1,228 @@
+import { roundMediaTime } from "@/wasm";
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV27ToV28({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 28) {
+ return { project, skipped: true, reason: "already v28" };
+ }
+ if (version !== 27) {
+ return { project, skipped: true, reason: "not v27" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 28,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const nextProject = { ...project };
+
+ if (isRecord(project.metadata)) {
+ nextProject.metadata = migrateTimeFields({
+ record: project.metadata,
+ keys: ["duration"],
+ });
+ }
+
+ if (isRecord(project.timelineViewState)) {
+ nextProject.timelineViewState = migrateTimeFields({
+ record: project.timelineViewState,
+ keys: ["playheadTime"],
+ });
+ }
+
+ if (Array.isArray(project.scenes)) {
+ nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
+ }
+
+ return nextProject;
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const nextScene = { ...scene };
+
+ if (Array.isArray(scene.bookmarks)) {
+ nextScene.bookmarks = scene.bookmarks.map((bookmark) =>
+ migrateBookmark({ bookmark }),
+ );
+ }
+
+ if (isRecord(scene.tracks)) {
+ nextScene.tracks = migrateTracks({ tracks: scene.tracks });
+ }
+
+ return nextScene;
+}
+
+function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
+ const nextTracks = { ...tracks };
+
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
+ }
+
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+
+ return nextTracks;
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) {
+ return element;
+ }
+
+ const nextElement = migrateTimeFields({
+ record: element,
+ keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"],
+ });
+
+ if (isRecord(element.animations)) {
+ nextElement.animations = migrateAnimations({
+ animations: element.animations,
+ });
+ }
+
+ return nextElement;
+}
+
+function migrateAnimations({
+ animations,
+}: {
+ animations: ProjectRecord;
+}): ProjectRecord {
+ if (!isRecord(animations.channels)) {
+ return animations;
+ }
+
+ return {
+ ...animations,
+ channels: Object.fromEntries(
+ Object.entries(animations.channels).map(([channelId, channel]) => [
+ channelId,
+ migrateAnimationChannel({ channel }),
+ ]),
+ ),
+ };
+}
+
+function migrateAnimationChannel({ channel }: { channel: unknown }): unknown {
+ if (!isRecord(channel) || !Array.isArray(channel.keys)) {
+ return channel;
+ }
+
+ return {
+ ...channel,
+ keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })),
+ };
+}
+
+function migrateAnimationKeyframe({
+ keyframe,
+}: {
+ keyframe: unknown;
+}): unknown {
+ if (!isRecord(keyframe)) {
+ return keyframe;
+ }
+
+ const nextKeyframe = migrateTimeFields({
+ record: keyframe,
+ keys: ["time"],
+ });
+
+ if (isRecord(keyframe.leftHandle)) {
+ nextKeyframe.leftHandle = migrateTimeFields({
+ record: keyframe.leftHandle,
+ keys: ["dt"],
+ });
+ }
+
+ if (isRecord(keyframe.rightHandle)) {
+ nextKeyframe.rightHandle = migrateTimeFields({
+ record: keyframe.rightHandle,
+ keys: ["dt"],
+ });
+ }
+
+ return nextKeyframe;
+}
+
+function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown {
+ if (!isRecord(bookmark)) {
+ return bookmark;
+ }
+
+ return migrateTimeFields({
+ record: bookmark,
+ keys: ["time", "duration"],
+ });
+}
+
+function migrateTimeFields({
+ record,
+ keys,
+}: {
+ record: ProjectRecord;
+ keys: string[];
+}): ProjectRecord {
+ const nextRecord = { ...record };
+
+ for (const key of keys) {
+ if (!(key in record)) {
+ continue;
+ }
+
+ nextRecord[key] = normalizeMediaTimeValue({ value: record[key] });
+ }
+
+ return nextRecord;
+}
+
+function normalizeMediaTimeValue({ value }: { value: unknown }): unknown {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return value;
+ }
+ return roundMediaTime({ time: value });
+}
diff --git a/apps/web/src/services/storage/migrations/v27-to-v28.ts b/apps/web/src/services/storage/migrations/v27-to-v28.ts
new file mode 100644
index 00000000..aaec6256
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v27-to-v28.ts
@@ -0,0 +1,14 @@
+import { StorageMigration, type StorageMigrationRunArgs } from "./base";
+import type { MigrationResult, ProjectRecord } from "./transformers/types";
+import { transformProjectV27ToV28 } from "./transformers/v27-to-v28";
+
+export class V27toV28Migration extends StorageMigration {
+ from = 27;
+ to = 28;
+
+ async run({
+ project,
+ }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
+ return transformProjectV27ToV28({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts
index 858198eb..a388e807 100644
--- a/apps/web/src/services/storage/service.ts
+++ b/apps/web/src/services/storage/service.ts
@@ -22,12 +22,15 @@ import {
runStorageMigrations,
} from "@/services/storage/migrations";
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
+import { roundMediaTime } from "@/wasm";
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item): Bookmark | null => {
- if (typeof item === "number") return { time: item };
+ if (typeof item === "number") {
+ return { time: roundMediaTime({ time: item }) };
+ }
const obj = item as Record<string, unknown>;
if (
typeof obj !== "object" ||
@@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
return null;
}
return {
- time: obj.time,
+ time: roundMediaTime({ time: obj.time }),
...(typeof obj.note === "string" && { note: obj.note }),
...(typeof obj.color === "string" && { color: obj.color }),
- ...(typeof obj.duration === "number" && { duration: obj.duration }),
+ ...(typeof obj.duration === "number" && {
+ duration: roundMediaTime({ time: obj.duration }),
+ }),
};
})
.filter((b): b is Bookmark => b !== null);
@@ -198,9 +203,11 @@ class StorageService {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
- duration:
- serializedProject.metadata.duration ??
- getProjectDurationFromScenes({ scenes }),
+ duration: roundMediaTime({
+ time:
+ serializedProject.metadata.duration ??
+ getProjectDurationFromScenes({ scenes }),
+ }),
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
},
@@ -253,11 +260,13 @@ class StorageService {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
- duration:
- serializedProject.metadata.duration ??
- getProjectDurationFromScenes({
- scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
- }),
+ duration: roundMediaTime({
+ time:
+ serializedProject.metadata.duration ??
+ getProjectDurationFromScenes({
+ scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
+ }),
+ }),
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
});
diff --git a/apps/web/src/sounds/sounds-store.ts b/apps/web/src/sounds/sounds-store.ts
index b9b3f070..13ced962 100644
--- a/apps/web/src/sounds/sounds-store.ts
+++ b/apps/web/src/sounds/sounds-store.ts
@@ -4,6 +4,7 @@ import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { EditorCore } from "@/core";
import { buildLibraryAudioElement } from "@/timeline/element-utils";
+import { mediaTimeFromSeconds } from "@/wasm";
interface SoundsStore {
topSoundEffects: SoundEffect[];
@@ -227,7 +228,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
const element = buildLibraryAudioElement({
sourceUrl: audioUrl,
name: sound.name,
- duration: sound.duration,
+ duration: mediaTimeFromSeconds({ seconds: sound.duration }),
startTime: currentTime,
buffer,
});
diff --git a/apps/web/src/subtitles/build-subtitle-text-element.ts b/apps/web/src/subtitles/build-subtitle-text-element.ts
index c4ac21ac..4e0bdb85 100644
--- a/apps/web/src/subtitles/build-subtitle-text-element.ts
+++ b/apps/web/src/subtitles/build-subtitle-text-element.ts
@@ -5,7 +5,7 @@ import {
setCanvasLetterSpacing,
} from "@/text/layout";
import { DEFAULTS } from "@/timeline/defaults";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTimeFromSeconds } from "@/wasm";
import type { CreateTextElement } from "@/timeline";
import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
@@ -310,8 +310,8 @@ export function buildSubtitleTextElement({
...DEFAULTS.text.element,
name: `Caption ${index + 1}`,
content,
- duration: Math.round(caption.duration * TICKS_PER_SECOND),
- startTime: Math.round(caption.startTime * TICKS_PER_SECOND),
+ duration: mediaTimeFromSeconds({ seconds: caption.duration }),
+ startTime: mediaTimeFromSeconds({ seconds: caption.startTime }),
fontSize: style.fontSize,
fontFamily: style.fontFamily,
color: style.color,
diff --git a/apps/web/src/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx
index 25a49893..ffabe3b8 100644
--- a/apps/web/src/text/components/assets-view.tsx
+++ b/apps/web/src/text/components/assets-view.tsx
@@ -3,11 +3,12 @@ import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { useEditor } from "@/editor/use-editor";
import { DEFAULTS } from "@/timeline/defaults";
import { buildTextElement } from "@/timeline/element-utils";
+import type { MediaTime } from "@/wasm";
export function TextView() {
const editor = useEditor();
- const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
+ const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => {
const activeScene = editor.scenes.getActiveScene();
if (!activeScene) return;
diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts
new file mode 100644
index 00000000..94b32ced
--- /dev/null
+++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, test } from "bun:test";
+import type { Transform } from "@/rendering";
+import type { SceneTracks, VideoElement } from "@/timeline";
+import { applyElementUpdate } from "@/timeline/update-pipeline";
+import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
+
+function buildTransform(): Transform {
+ return {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ };
+}
+
+function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement {
+ return {
+ id: "video-1",
+ type: "video",
+ name: "Video 1",
+ startTime: ZERO_MEDIA_TIME,
+ duration: mediaTime({ ticks: 10 }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ mediaId: "media-1",
+ transform: buildTransform(),
+ opacity: 1,
+ ...overrides,
+ };
+}
+
+function buildTracks(element: VideoElement): SceneTracks {
+ return {
+ overlay: [],
+ main: {
+ id: "main-track",
+ type: "video",
+ name: "Main",
+ muted: false,
+ hidden: false,
+ elements: [element],
+ },
+ audio: [],
+ };
+}
+
+describe("applyElementUpdate", () => {
+ test("rounds retimed durations back to integer media time", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks(element);
+
+ const updatedElement = applyElementUpdate({
+ element,
+ patch: {
+ retime: { rate: 1.5 },
+ },
+ context: {
+ tracks,
+ trackId: tracks.main.id,
+ },
+ });
+
+ expect(updatedElement.duration).toBe(7);
+ expect(Number.isInteger(updatedElement.duration)).toBe(true);
+ });
+});
diff --git a/apps/web/src/timeline/animation-properties.ts b/apps/web/src/timeline/animation-properties.ts
index 31479a58..308f6cf0 100644
--- a/apps/web/src/timeline/animation-properties.ts
+++ b/apps/web/src/timeline/animation-properties.ts
@@ -8,6 +8,7 @@ import type {
} 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";
@@ -393,7 +394,7 @@ export function upsertElementKeyframe({
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
- time: number;
+ time: MediaTime;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
diff --git a/apps/web/src/timeline/animation-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts
index f7bf549a..e64de11f 100644
--- a/apps/web/src/timeline/animation-snap-points.ts
+++ b/apps/web/src/timeline/animation-snap-points.ts
@@ -1,6 +1,7 @@
import { getElementKeyframes } from "@/animation";
import type { SceneTracks } from "@/timeline";
import type { SnapPoint } from "@/timeline/snapping";
+import { addMediaTime } from "@/wasm";
export function getAnimationKeyframeSnapPointsForTimeline({
tracks,
@@ -22,7 +23,7 @@ export function getAnimationKeyframeSnapPointsForTimeline({
animations: element.animations,
})) {
snapPoints.push({
- time: element.startTime + keyframe.time,
+ time: addMediaTime({ a: element.startTime, b: keyframe.time }),
type: "keyframe",
elementId: element.id,
trackId: track.id,
diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx
index 20e22c57..43ba4ffc 100644
--- a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx
+++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx
@@ -7,7 +7,6 @@ import type { BookmarkDragState } from "../hooks/use-bookmark-drag";
import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme";
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout";
import { DEFAULT_FPS } from "@/fps/defaults";
-import { snappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
@@ -26,6 +25,14 @@ import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp, formatNumberForDisplay } from "@/utils/math";
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
+import {
+ type MediaTime,
+ mediaTimeFromSeconds,
+ mediaTimeToSeconds,
+ snapSeekMediaTime,
+ subMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
@@ -39,12 +46,12 @@ function seekToBookmarkTime({
time,
}: {
editor: EditorCore;
- time: number;
+ time: MediaTime;
}) {
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
- const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
+ const snappedTime = snapSeekMediaTime({ time, duration, fps: rate });
editor.playback.seek({ time: snappedTime });
}
@@ -137,7 +144,7 @@ function TimelineBookmark({
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
const time = bookmark.time;
- const bookmarkDuration = bookmark.duration ?? 0;
+ const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME;
const durationWidth =
bookmarkDuration > 0
? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
@@ -182,7 +189,7 @@ function TimelineBookmark({
left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`,
}}
- aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
+ aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`}
type="button"
onMouseDown={handleMouseDown}
onClick={handleClick}
@@ -286,8 +293,8 @@ function BookmarkPopoverContent({
onPopoverClose,
}: {
bookmark: Bookmark;
- time: number;
- timelineDuration: number;
+ time: MediaTime;
+ timelineDuration: MediaTime;
onPopoverClose: () => void;
}) {
const editor = useEditor();
@@ -314,9 +321,8 @@ function BookmarkPopoverContent({
note,
color,
duration,
- }: Partial<{ note: string; color: string; duration: number }>) => {
- const updates: Partial<{ note: string; color: string; duration: number }> =
- {};
+ }: Partial<Omit<Bookmark, "time">>) => {
+ const updates: Partial<Omit<Bookmark, "time">> = {};
if (note !== undefined && note !== bookmark.note) updates.note = note;
if (
color !== undefined &&
@@ -330,6 +336,15 @@ function BookmarkPopoverContent({
if (Object.keys(updates).length === 0) return;
editor.scenes.updateBookmark({ time, updates });
};
+ const maxDuration = mediaTimeToSeconds({
+ time:
+ timelineDuration > time
+ ? subMediaTime({ a: timelineDuration, b: time })
+ : ZERO_MEDIA_TIME,
+ });
+ const durationSeconds = mediaTimeToSeconds({
+ time: bookmark.duration ?? ZERO_MEDIA_TIME,
+ });
return (
<>
@@ -387,7 +402,7 @@ function BookmarkPopoverContent({
type="number"
min={0}
step={0.1}
- value={bookmark.duration ?? 0}
+ value={durationSeconds}
onChange={(event) => {
const parsed = parseFloat(event.target.value);
const value = Number.isNaN(parsed)
@@ -395,9 +410,11 @@ function BookmarkPopoverContent({
: clamp({
value: parsed,
min: 0,
- max: Math.max(0, timelineDuration - time),
+ max: maxDuration,
});
- handleUpdate({ duration: value });
+ handleUpdate({
+ duration: mediaTimeFromSeconds({ seconds: value }),
+ });
}}
className="h-8 text-sm"
containerClassName="w-full"
diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
index 35ee9cca..4c03ef8c 100644
--- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
+++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
@@ -8,7 +8,6 @@ import {
import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
-import { roundToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
import {
buildTimelineSnapPoints,
@@ -21,15 +20,16 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import type { Bookmark } from "@/timeline";
+import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export interface BookmarkDragState {
isDragging: boolean;
- bookmarkTime: number | null;
- currentTime: number;
+ bookmarkTime: MediaTime | null;
+ currentTime: MediaTime;
}
interface PendingBookmarkDrag {
- bookmarkTime: number;
+ bookmarkTime: MediaTime;
startMouseX: number;
startMouseY: number;
}
@@ -58,7 +58,7 @@ export function useBookmarkDrag({
const [dragState, setDragState] = useState<BookmarkDragState>({
isDragging: false,
bookmarkTime: null,
- currentTime: 0,
+ currentTime: ZERO_MEDIA_TIME,
});
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
@@ -69,8 +69,8 @@ export function useBookmarkDrag({
bookmarkTime,
initialCurrentTime,
}: {
- bookmarkTime: number;
- initialCurrentTime: number;
+ bookmarkTime: MediaTime;
+ initialCurrentTime: MediaTime;
}) => {
setDragState({
isDragging: true,
@@ -85,7 +85,7 @@ export function useBookmarkDrag({
setDragState({
isDragging: false,
bookmarkTime: null,
- currentTime: 0,
+ currentTime: ZERO_MEDIA_TIME,
});
}, []);
@@ -94,9 +94,9 @@ export function useBookmarkDrag({
rawTime,
excludeBookmarkTime,
}: {
- rawTime: number;
- excludeBookmarkTime: number;
- }): { snappedTime: number; snapPoint: SnapPoint | null } => {
+ rawTime: MediaTime;
+ excludeBookmarkTime: MediaTime;
+ }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap) {
return { snappedTime: rawTime, snapPoint: null };
@@ -162,11 +162,12 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
- const frameSnappedTime =
- roundToFrame({
- time: Math.max(0, Math.min(mouseTime, duration)),
- rate: activeProject.settings.fps,
- }) ?? Math.max(0, Math.min(mouseTime, duration));
+ const clampedTime =
+ mouseTime > duration ? duration : mouseTime;
+ const frameSnappedTime = roundFrameTime({
+ time: clampedTime,
+ fps: activeProject.settings.fps,
+ });
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
@@ -193,10 +194,12 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
- const clampedTime = Math.max(0, Math.min(mouseTime, duration));
- const frameSnappedTime =
- roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ??
- clampedTime;
+ const clampedTime =
+ mouseTime > duration ? duration : mouseTime;
+ const frameSnappedTime = roundFrameTime({
+ time: clampedTime,
+ fps: activeProject.settings.fps,
+ });
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
@@ -234,10 +237,8 @@ export function useBookmarkDrag({
return;
}
- const clampedTime = Math.max(
- 0,
- Math.min(dragState.currentTime, duration),
- );
+ const clampedTime =
+ dragState.currentTime > duration ? duration : dragState.currentTime;
editor.scenes.moveBookmark({
fromTime: dragState.bookmarkTime,
diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx
index 8e2b4bcf..2c254c84 100644
--- a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx
+++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx
@@ -5,6 +5,7 @@ import {
type PreviewOverlaySourceResult,
} from "@/preview/overlays";
import { getBookmarksActiveAtTime } from "./utils";
+import type { MediaTime } from "@/wasm";
export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
id: "bookmark-notes",
@@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
function BookmarkNotesOverlay({
bookmarks,
}: {
- bookmarks: Array<{ time: number; note: string; color?: string }>;
+ bookmarks: Array<{ time: MediaTime; note: string; color?: string }>;
}) {
return (
<div className="flex flex-col gap-1.5" aria-live="polite">
@@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({
isVisible,
}: {
bookmarks: Bookmark[];
- time: number;
+ time: MediaTime;
isVisible: boolean;
}): PreviewOverlaySourceResult {
const bookmarksWithNotes = getBookmarksActiveAtTime({
diff --git a/apps/web/src/timeline/bookmarks/snap-source.ts b/apps/web/src/timeline/bookmarks/snap-source.ts
index 32a84baa..64d0c3c3 100644
--- a/apps/web/src/timeline/bookmarks/snap-source.ts
+++ b/apps/web/src/timeline/bookmarks/snap-source.ts
@@ -1,12 +1,13 @@
import type { Bookmark } from "@/timeline";
import type { SnapPoint } from "@/timeline/snapping";
+import type { MediaTime } from "@/wasm";
export function getBookmarkSnapPoints({
bookmarks,
excludeBookmarkTime,
}: {
bookmarks: Bookmark[];
- excludeBookmarkTime?: number;
+ excludeBookmarkTime?: MediaTime;
}): SnapPoint[] {
return bookmarks.flatMap((bookmark) => {
if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) {
diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts
index d05fe3a9..702e66c3 100644
--- a/apps/web/src/timeline/bookmarks/utils.ts
+++ b/apps/web/src/timeline/bookmarks/utils.ts
@@ -1,13 +1,13 @@
import type { Bookmark } from "@/timeline";
import type { FrameRate } from "opencut-wasm";
-import { roundToFrame } from "opencut-wasm";
+import { addMediaTime, roundFrameTime, type MediaTime } from "@/wasm";
function bookmarkTimeEqual({
bookmarkTime,
frameTime,
}: {
- bookmarkTime: number;
- frameTime: number;
+ bookmarkTime: MediaTime;
+ frameTime: MediaTime;
}): boolean {
return bookmarkTime === frameTime;
}
@@ -17,7 +17,7 @@ export function findBookmarkIndex({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): number {
return bookmarks.findIndex((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
@@ -29,7 +29,7 @@ export function isBookmarkAtTime({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): boolean {
return bookmarks.some((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
@@ -41,7 +41,7 @@ export function toggleBookmarkInArray({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): Bookmark[] {
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
@@ -58,7 +58,7 @@ export function removeBookmarkFromArray({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): Bookmark[] {
return bookmarks.filter(
(bookmark) =>
@@ -72,7 +72,7 @@ export function updateBookmarkInArray({
updates,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
updates: Partial<Omit<Bookmark, "time">>;
}): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime });
@@ -92,8 +92,8 @@ export function moveBookmarkInArray({
toTime,
}: {
bookmarks: Bookmark[];
- fromTime: number;
- toTime: number;
+ fromTime: MediaTime;
+ toTime: MediaTime;
}): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
if (index === -1) {
@@ -110,10 +110,10 @@ export function getFrameTime({
time,
fps,
}: {
- time: number;
+ time: MediaTime;
fps: FrameRate;
-}): number {
- return roundToFrame({ time, rate: fps }) ?? time;
+}): MediaTime {
+ return roundFrameTime({ time, fps });
}
export function getBookmarkAtTime({
@@ -121,7 +121,7 @@ export function getBookmarkAtTime({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): Bookmark | null {
const index = findBookmarkIndex({ bookmarks, frameTime });
return index === -1 ? null : bookmarks[index];
@@ -132,13 +132,13 @@ export function getBookmarksActiveAtTime({
time,
}: {
bookmarks: Bookmark[];
- time: number;
+ time: MediaTime;
}): Bookmark[] {
return bookmarks.filter((bookmark) => {
const start = bookmark.time;
const end =
bookmark.duration != null && bookmark.duration > 0
- ? start + bookmark.duration
+ ? addMediaTime({ a: start, b: bookmark.duration })
: start;
return time >= start && time <= end;
});
diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts
index be2495e4..f68a4b72 100644
--- a/apps/web/src/timeline/components/drop-target.ts
+++ b/apps/web/src/timeline/components/drop-target.ts
@@ -3,7 +3,12 @@ import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
import { resolveTrackPlacement } from "@/timeline/placement";
import { TIMELINE_TRACK_GAP_PX } from "./layout";
import { getTrackHeight } from "./track-layout";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ mediaTime,
+ type MediaTime,
+ roundMediaTime,
+ TICKS_PER_SECOND,
+} from "@/wasm";
function findElementAtPosition({
mouseX,
@@ -20,9 +25,11 @@ function findElementAtPosition({
pixelsPerSecond: number;
zoomLevel: number;
}): { elementId: string; trackId: string } | null {
- const time = Math.round(
- (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
- );
+ const time = mediaTime({
+ ticks: Math.round(
+ (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
+ ),
+ });
const track = tracks[trackIndex];
if (!track || !("elements" in track)) return null;
@@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null;
function fallbackNewTrackDropTarget({
xPosition,
}: {
- xPosition: number;
+ xPosition: MediaTime;
}): DropTarget {
return {
trackIndex: 0,
@@ -111,13 +118,16 @@ export function computeDropTarget({
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const mainTrackIndex = tracks.overlay.length;
const xPosition =
- typeof startTimeOverride === "number"
+ startTimeOverride !== undefined
? startTimeOverride
: isExternalDrop
? playheadTime
- : Math.round(
- Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
- );
+ : mediaTime({
+ ticks: Math.round(
+ Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) *
+ TICKS_PER_SECOND,
+ ),
+ });
if (orderedTracks.length === 0) {
const placementResult = resolveTrackPlacement({
@@ -221,7 +231,10 @@ export function computeDropTarget({
}
if (placementResult.kind === "existingTrack") {
- const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
+ const adjustedXPosition =
+ placementResult.adjustedStartTime !== undefined
+ ? roundMediaTime({ time: placementResult.adjustedStartTime })
+ : xPosition;
return {
trackIndex: placementResult.trackIndex,
diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx
index 88fb93b7..490ad1cc 100644
--- a/apps/web/src/timeline/components/index.tsx
+++ b/apps/web/src/timeline/components/index.tsx
@@ -29,6 +29,7 @@ import {
useState,
type ReactNode,
} from "react";
+import type { MediaTime } from "@/wasm";
import type { ElementDragView, DropTarget } from "@/timeline";
import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead";
@@ -132,7 +133,7 @@ export function Timeline() {
[scene],
);
const mainTrackId = scene?.tracks.main.id ?? null;
- const seek = (time: number) => editor.playback.seek({ time });
+ const seek = (time: MediaTime) => editor.playback.seek({ time });
const timelineRef = useRef<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
@@ -289,12 +290,12 @@ export function Timeline() {
const { dragView, handleElementMouseDown, handleElementClick } =
useElementInteraction({
- zoomLevel,
- tracksContainerRef,
- tracksScrollRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
+ zoomLevel,
+ tracksContainerRef,
+ tracksScrollRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
const isElementDragging = dragView.kind === "dragging";
const {
@@ -777,8 +778,8 @@ function TimelineTrackRows({
const draggingElementIds = useMemo(
() =>
dragView.kind === "dragging"
- ? dragView.memberTimeOffsets
- : (null as ReadonlyMap<string, number> | null),
+ ? dragView.memberTimeOffsets
+ : (null as ReadonlyMap<string, MediaTime> | null),
[dragView],
);
const sortedTracks = useMemo(() => {
diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx
index 35785332..cb102c71 100644
--- a/apps/web/src/timeline/components/timeline-element.tsx
+++ b/apps/web/src/timeline/components/timeline-element.tsx
@@ -49,7 +49,7 @@ import {
import { buildWaveformGainSamples } from "@/timeline/audio-state";
import { getTimelinePixelsPerSecond } from "@/timeline";
import { buildWaveformSourceKey } from "@/media/waveform-summary";
-import { TICKS_PER_SECOND } from "@/wasm/ticks";
+import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
import {
getActionDefinition,
type TAction,
@@ -96,7 +96,7 @@ const PixelsPerSecondContext = createContext<number | null>(null);
const THUMBNAIL_ASPECT_RATIO = 16 / 9;
interface KeyframeIndicator {
- time: number;
+ time: MediaTime;
offsetPx: number;
keyframes: SelectedKeyframeRef[];
}
@@ -112,11 +112,11 @@ export function buildKeyframeIndicator({
keyframe: ElementKeyframe;
trackId: string;
elementId: string;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
zoomLevel: number;
elementLeft: number;
}): {
- time: number;
+ time: MediaTime;
offsetPx: number;
keyframeRef: SelectedKeyframeRef;
} {
@@ -149,7 +149,7 @@ export function getKeyframeIndicators({
keyframes: ElementKeyframe[];
trackId: string;
elementId: string;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
zoomLevel: number;
elementLeft: number;
elementWidth: number;
@@ -158,7 +158,7 @@ export function getKeyframeIndicators({
return [];
}
- const keyframesByTime = new Map<number, KeyframeIndicator>();
+ const keyframesByTime = new Map<MediaTime, KeyframeIndicator>();
for (const keyframe of keyframes) {
const indicator = buildKeyframeIndicator({
keyframe,
@@ -263,7 +263,7 @@ export function TimelineElement({
: 0;
const elementStartTime =
isDragging && isBeingDragged
- ? dragView.currentTime + dragTimeOffset
+ ? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset })
: renderElement.startTime;
const displayedStartTime = elementStartTime;
const displayedDuration = renderElement.duration;
@@ -385,7 +385,7 @@ export function TimelineElement({
? `${baseTrackHeight + expansionHeight}px`
: "100%",
transform:
- isBeingDragged && isDragging
+ isDragging && isBeingDragged
? `translate3d(0, ${dragOffsetY}px, 0)`
: undefined,
}}
@@ -664,7 +664,7 @@ function KeyframeIndicators({
}: {
indicators: KeyframeIndicator[];
dragState: KeyframeDragState;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
elementLeft: number;
onKeyframeMouseDown: (params: {
event: React.MouseEvent;
@@ -674,13 +674,13 @@ function KeyframeIndicators({
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
+ indicatorTime: MediaTime;
}) => void;
getVisualOffsetPx: (params: {
- indicatorTime: number;
+ indicatorTime: MediaTime;
indicatorOffsetPx: number;
isBeingDragged: boolean;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
elementLeft: number;
}) => number;
}) {
@@ -758,7 +758,7 @@ function ExpandedKeyframeLanes({
keyframes: ElementKeyframe[];
trackId: string;
elementId: string;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
zoomLevel: number;
elementLeft: number;
keyframeDragState: KeyframeDragState;
@@ -779,13 +779,13 @@ function ExpandedKeyframeLanes({
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
+ indicatorTime: MediaTime;
}) => void;
getVisualOffsetPx: (params: {
- indicatorTime: number;
+ indicatorTime: MediaTime;
indicatorOffsetPx: number;
isBeingDragged: boolean;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
elementLeft: number;
}) => number;
}) {
diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx
index 4dd3329f..5b1e45ab 100644
--- a/apps/web/src/timeline/components/timeline-playhead.tsx
+++ b/apps/web/src/timeline/components/timeline-playhead.tsx
@@ -7,7 +7,14 @@ import {
timelineTimeToSnappedPixels,
} from "@/timeline";
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ maxMediaTime,
+ mediaTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { useEditor } from "@/editor/use-editor";
import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout";
import { TIMELINE_LAYERS } from "./layers";
@@ -72,17 +79,24 @@ export function TimelinePlayhead({
event.preventDefault();
const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
+ const ticksPerFrame = mediaTime({
+ ticks: Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ ),
+ });
const direction = event.key === "ArrowRight" ? 1 : -1;
const now = editor.playback.getCurrentTime();
- const nextTime = Math.max(
- 0,
- Math.min(duration, now + direction * ticksPerFrame),
- );
+ const nextTime =
+ direction > 0
+ ? addMediaTime({ a: now, b: ticksPerFrame })
+ : subMediaTime({ a: now, b: ticksPerFrame });
- editor.playback.seek({ time: nextTime });
+ editor.playback.seek({
+ time: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: duration < nextTime ? duration : nextTime,
+ }),
+ });
};
return (
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts
index cd03300e..7fca2676 100644
--- a/apps/web/src/timeline/controllers/drag-drop-controller.ts
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -6,7 +6,7 @@ import {
toElementDurationTicks,
} from "@/timeline/creation";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { roundToFrame, type FrameRate } from "opencut-wasm";
+import type { FrameRate } from "opencut-wasm";
import {
buildTextElement,
buildGraphicElement,
@@ -30,6 +30,7 @@ import type {
import type { TimelineDragData } from "@/timeline/drag";
import type { MediaAsset } from "@/media/types";
import type { ProcessedMediaAsset } from "@/media/processing";
+import { roundFrameTime, type MediaTime } from "@/wasm";
// --- Config ---
@@ -41,7 +42,7 @@ export interface DragDropConfig {
getActiveProjectFps: () => FrameRate | null;
getActiveProjectId: () => string | null;
getSceneTracks: () => SceneTracks;
- getCurrentPlayheadTime: () => number;
+ getCurrentPlayheadTime: () => MediaTime;
getMediaAssets: () => MediaAsset[];
dragSource: TimelineDragSource;
addMediaAsset: (args: {
@@ -116,7 +117,7 @@ function getDurationForDrag({
}: {
dragData: TimelineDragData;
mediaAssets: MediaAsset[];
-}): number {
+}): MediaTime {
if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION;
const media = mediaAssets.find((asset) => asset.id === dragData.id);
return toElementDurationTicks({ seconds: media?.duration });
@@ -225,8 +226,7 @@ export class DragDropController {
const fps = this.config.getActiveProjectFps();
target.xPosition = fps
- ? (roundToFrame({ time: target.xPosition, rate: fps }) ??
- target.xPosition)
+ ? roundFrameTime({ time: target.xPosition, fps })
: target.xPosition;
this.setOver({ dropTarget: target, elementType });
diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts
index cc19daaa..d0d6426a 100644
--- a/apps/web/src/timeline/controllers/element-interaction-controller.ts
+++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts
@@ -7,9 +7,17 @@ import {
type MoveGroup,
} from "@/timeline/group-move";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ maxMediaTime,
+ type MediaTime,
+ mediaTime,
+ roundFrameTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
-import { roundToFrame, type FrameRate } from "opencut-wasm";
+import type { FrameRate } from "opencut-wasm";
import { computeDropTarget } from "@/timeline/components/drop-target";
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
@@ -52,7 +60,7 @@ export interface ElementSelectionApi {
}
export interface PlaybackReader {
- getCurrentTime: () => number;
+ getCurrentTime: () => MediaTime;
}
export interface TimelineOps {
@@ -86,8 +94,8 @@ interface MousedownSnapshot {
readonly origin: Point;
readonly elementId: string;
readonly trackId: string;
- readonly startElementTime: number;
- readonly clickOffsetTime: number;
+ readonly startElementTime: MediaTime;
+ readonly clickOffsetTime: MediaTime;
readonly selectedElements: readonly ElementRef[];
}
@@ -100,7 +108,7 @@ interface DragProgress {
// indicator, drop-line, commit path) see the same entity every frame
// instead of a churning UUID.
reservedNewTrackIds: readonly string[];
- currentTime: number;
+ currentTime: MediaTime;
currentMouseX: number;
currentMouseY: number;
groupMoveResult: GroupMoveResult | null;
@@ -120,10 +128,10 @@ function pixelToClickOffsetTime(
clientX: number,
elementRect: DOMRect,
zoomLevel: number,
-): number {
+): MediaTime {
const clickOffsetX = clientX - elementRect.left;
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
- return Math.round(seconds * TICKS_PER_SECOND);
+ return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) });
}
function verticalDirection(
@@ -156,17 +164,20 @@ function frameSnappedMouseTime({
clientX: number;
scrollContainer: HTMLDivElement;
zoomLevel: number;
- clickOffsetTime: number;
+ clickOffsetTime: MediaTime;
fps: FrameRate;
-}): number {
+}): MediaTime {
const mouseTime = getMouseTimeFromClientX({
clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft: scrollContainer.scrollLeft,
});
- const adjusted = Math.max(0, mouseTime - clickOffsetTime);
- return roundToFrame({ time: adjusted, rate: fps }) ?? adjusted;
+ const adjusted = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({ a: mouseTime, b: clickOffsetTime }),
+ });
+ return roundFrameTime({ time: adjusted, fps });
}
function resolveDropTarget({
@@ -187,7 +198,7 @@ function resolveDropTarget({
tracks: SceneTracks;
viewport: ViewportAdapter;
zoomLevel: number;
- snappedTime: number;
+ snappedTime: MediaTime;
verticalDragDirection: "up" | "down" | null;
}): DropTarget | null {
const containerRect = viewport
@@ -231,7 +242,7 @@ function resolveGroupMoveForDrop({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
dropTarget: DropTarget;
reservedNewTrackIds: readonly string[];
}): GroupMoveResult | null {
@@ -286,7 +297,7 @@ export class ElementInteractionController {
get view(): ElementDragView {
if (this.session.kind !== "dragging") return IDLE_VIEW;
const { mousedown, drag } = this.session;
- const memberTimeOffsets = new Map<string, number>();
+ const memberTimeOffsets = new Map<string, MediaTime>();
for (const member of drag.moveGroup.members) {
memberTimeOffsets.set(member.elementId, member.timeOffset);
}
@@ -427,9 +438,9 @@ export class ElementInteractionController {
}
private snapResult(
- frameSnappedTime: number,
+ frameSnappedTime: MediaTime,
group: MoveGroup,
- ): { snappedTime: number; snapPoint: SnapPoint | null } {
+ ): { snappedTime: MediaTime; snapPoint: SnapPoint | null } {
const { snap, input, scene, viewport, playback } = this.deps;
if (!snap.isEnabled() || input.isShiftHeld()) {
@@ -461,7 +472,7 @@ export class ElementInteractionController {
clientY: number;
mousedown: MousedownSnapshot;
drag: DragProgress;
- snappedTime: number;
+ snappedTime: MediaTime;
}): void {
const { scene, viewport } = this.deps;
const tracks = scene.getTracks();
diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
index 51c60dee..e3a9cd96 100644
--- a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
+++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
@@ -1,7 +1,16 @@
import type { MouseEvent as ReactMouseEvent } from "react";
-import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm";
+import type { FrameRate } from "opencut-wasm";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ clampMediaTime,
+ type MediaTime,
+ mediaTime,
+ roundFrameTicks,
+ snapSeekMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
import { timelineTimeToSnappedPixels } from "@/timeline";
import { getKeyframeById } from "@/animation";
@@ -48,7 +57,7 @@ export interface KeyframeDragConfig {
zoomLevel: number;
getFps: () => FrameRate | null;
element: TimelineElement;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
selectedKeyframes: SelectedKeyframeRef[];
isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean;
setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void;
@@ -62,8 +71,8 @@ export interface KeyframeDragConfig {
isAdditive: boolean;
}) => void;
executeCommand: (command: Command) => void;
- seek: (args: { time: number }) => void;
- getTotalDuration: () => number;
+ seek: (args: { time: MediaTime }) => void;
+ getTotalDuration: () => MediaTime;
}
export interface KeyframeDragConfigRef {
@@ -161,7 +170,7 @@ export class KeyframeDragController {
event: ReactMouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
+ indicatorTime: MediaTime;
}): void {
event.stopPropagation();
@@ -174,14 +183,18 @@ export class KeyframeDragController {
const { displayedStartTime, getFps, getTotalDuration, seek } = this.config;
const fps = getFps();
+ const absoluteIndicatorTime = addMediaTime({
+ a: displayedStartTime,
+ b: indicatorTime,
+ });
const seekTime =
fps != null
- ? (snappedSeekTime({
- time: displayedStartTime + indicatorTime,
+ ? snapSeekMediaTime({
+ time: absoluteIndicatorTime,
duration: getTotalDuration(),
- rate: fps,
- }) ?? displayedStartTime + indicatorTime)
- : displayedStartTime + indicatorTime;
+ fps,
+ })
+ : absoluteIndicatorTime;
seek({ time: seekTime });
if (event.shiftKey) {
@@ -206,24 +219,23 @@ export class KeyframeDragController {
displayedStartTime,
elementLeft,
}: {
- indicatorTime: number;
+ indicatorTime: MediaTime;
indicatorOffsetPx: number;
isBeingDragged: boolean;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
elementLeft: number;
}): number {
if (!isBeingDragged || this.session.kind !== "active")
return indicatorOffsetPx;
- const clampedTime = Math.max(
- 0,
- Math.min(
- this.config.element.duration,
- indicatorTime + this.session.deltaTicks,
- ),
- );
+ const deltaTime = mediaTime({ ticks: this.session.deltaTicks });
+ const clampedTime = clampMediaTime({
+ time: addMediaTime({ a: indicatorTime, b: deltaTime }),
+ min: ZERO_MEDIA_TIME,
+ max: this.config.element.duration,
+ });
return (
timelineTimeToSnappedPixels({
- time: displayedStartTime + clampedTime,
+ time: addMediaTime({ a: displayedStartTime, b: clampedTime }),
zoomLevel: this.config.zoomLevel,
}) - elementLeft
);
@@ -270,10 +282,14 @@ export class KeyframeDragController {
elementId: ref.elementId,
propertyPath: ref.propertyPath,
keyframeId: ref.keyframeId,
- nextTime: Math.max(
- 0,
- Math.min(element.duration, keyframe.time + deltaTicks),
- ),
+ nextTime: clampMediaTime({
+ time: addMediaTime({
+ a: keyframe.time,
+ b: mediaTime({ ticks: deltaTicks }),
+ }),
+ min: ZERO_MEDIA_TIME,
+ max: element.duration,
+ }),
}),
];
});
@@ -313,8 +329,7 @@ export class KeyframeDragController {
((clientX - this.session.startMouseX) / pixelsPerSecond) *
TICKS_PER_SECOND,
);
- this.session.deltaTicks =
- roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks;
+ this.session.deltaTicks = roundFrameTicks({ ticks: rawDeltaTicks, fps });
this.notify();
}
diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts
index d2b7d82f..e914ff21 100644
--- a/apps/web/src/timeline/controllers/playhead-controller.ts
+++ b/apps/web/src/timeline/controllers/playhead-controller.ts
@@ -1,6 +1,11 @@
import type { MouseEvent as ReactMouseEvent } from "react";
-import { snappedSeekTime, type FrameRate } from "opencut-wasm";
-import { TICKS_PER_SECOND } from "@/wasm";
+import type { FrameRate } from "opencut-wasm";
+import {
+ mediaTime,
+ snapSeekMediaTime,
+ TICKS_PER_SECOND,
+ type MediaTime,
+} from "@/wasm";
import {
buildTimelineSnapPoints,
getTimelineSnapThresholdInTicks,
@@ -26,7 +31,7 @@ interface ScrubSession {
/** True once the mouse has moved during a ruler drag. */
hasMoved: boolean;
/** Most recent frame-snapped time set by scrub(). */
- currentTime: number | null;
+ currentTime: MediaTime | null;
}
type Session = { kind: "idle" } | ScrubSession;
@@ -35,7 +40,7 @@ type Session = { kind: "idle" } | ScrubSession;
export interface PlayheadConfig {
zoomLevel: number;
- duration: number;
+ duration: MediaTime;
getActiveProjectFps: () => FrameRate | null;
isShiftHeld: () => boolean;
getIsPlaying: () => boolean;
@@ -45,12 +50,12 @@ export interface PlayheadConfig {
getPlayheadEl: () => HTMLDivElement | null;
getSceneTracks: () => SceneTracks;
getSceneBookmarks: () => Bookmark[];
- seek: (time: number) => void;
+ seek: (time: MediaTime) => void;
setScrubbing: (isScrubbing: boolean) => void;
setTimelineViewState: (viewState: {
zoomLevel: number;
scrollLeft: number;
- playheadTime: number;
+ playheadTime: MediaTime;
}) => void;
}
@@ -69,8 +74,8 @@ function pixelToTime({
clientX: number;
rulerEl: HTMLDivElement;
zoomLevel: number;
- duration: number;
-}): number {
+ duration: MediaTime;
+}): MediaTime {
const rulerRect = rulerEl.getBoundingClientRect();
const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
const clampedX = Math.max(
@@ -84,7 +89,7 @@ function pixelToTime({
clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
- return Math.round(seconds * TICKS_PER_SECOND);
+ return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) });
}
// --- Controller ---
@@ -159,7 +164,7 @@ export class PlayheadController {
* Called on scroll and playback events to avoid React re-renders
* during animation frame updates.
*/
- updatePlayheadLeft(time: number): void {
+ updatePlayheadLeft(time: MediaTime): void {
const playheadEl = this.config.getPlayheadEl();
if (!playheadEl) return;
@@ -175,7 +180,7 @@ export class PlayheadController {
* Updates the playhead position and auto-scrolls to keep the playhead
* visible during playback.
*/
- handlePlaybackUpdate(time: number): void {
+ handlePlaybackUpdate(time: MediaTime): void {
this.updatePlayheadLeft(time);
// Auto-scroll only during playback, not while scrubbing.
@@ -244,8 +249,7 @@ export class PlayheadController {
zoomLevel,
duration,
});
- const frameTime =
- snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime;
+ const frameTime = snapSeekMediaTime({ time: rawTime, duration, fps });
const time = (() => {
if (!isElementSnappingEnabled || this.config.isShiftHeld())
diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
index cdf502a1..e8cbaa80 100644
--- a/apps/web/src/timeline/controllers/resize-controller.ts
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -1,6 +1,14 @@
import type { MouseEvent as ReactMouseEvent } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ maxMediaTime,
+ type MediaTime,
+ mediaTime,
+ minMediaTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+} from "@/wasm";
import {
computeGroupResize,
type GroupResizeMember,
@@ -46,7 +54,7 @@ export interface ResizeConfig {
snappingEnabled: boolean;
isShiftHeld: () => boolean;
getSceneTracks: () => SceneTracks;
- getCurrentPlayheadTime: () => number;
+ getCurrentPlayheadTime: () => MediaTime;
getActiveProjectFps: () => FrameRate | null;
selectedElements: ElementRef[];
discardPreview: () => void;
@@ -87,14 +95,32 @@ export function buildResizeMembers({
(el) => !selectedElementIds.has(el.id),
);
const leftNeighborBound = otherElements
- .filter((el) => el.startTime + el.duration <= element.startTime)
- .reduce(
- (bound, el) => Math.max(bound, el.startTime + el.duration),
- -Infinity,
- );
+ .filter(
+ (el) =>
+ addMediaTime({ a: el.startTime, b: el.duration }) <=
+ element.startTime,
+ )
+ .reduce<MediaTime | null>((bound, el) => {
+ const elementEnd = addMediaTime({
+ a: el.startTime,
+ b: el.duration,
+ });
+ return bound === null
+ ? elementEnd
+ : maxMediaTime({ a: bound, b: elementEnd });
+ }, null);
const rightNeighborBound = otherElements
- .filter((el) => el.startTime >= element.startTime + element.duration)
- .reduce((bound, el) => Math.min(bound, el.startTime), Infinity);
+ .filter(
+ (el) =>
+ el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }),
+ )
+ .reduce<MediaTime | null>(
+ (bound, el) =>
+ bound === null
+ ? el.startTime
+ : minMediaTime({ a: bound, b: el.startTime }),
+ null,
+ );
return [
{
@@ -237,12 +263,15 @@ export class ResizeController {
this.notify();
}
- private snappedDelta(session: ResizeSession, rawDeltaTicks: number): number {
+ private snappedDelta(
+ session: ResizeSession,
+ rawDeltaTime: MediaTime,
+ ): MediaTime {
const { snappingEnabled, isShiftHeld, zoomLevel } = this.config;
if (!snappingEnabled || isShiftHeld()) {
this.config.onSnapPointChange?.(null);
- return rawDeltaTicks;
+ return rawDeltaTime;
}
const tracks = this.config.getSceneTracks();
@@ -264,15 +293,15 @@ export class ResizeController {
let closestSnapPoint: SnapPoint | null = null;
let closestSnapDistance = Infinity;
- let deltaTicks = rawDeltaTicks;
+ let deltaTime = rawDeltaTime;
for (const member of session.members) {
const baseEdgeTime =
session.side === "left"
? member.startTime
- : member.startTime + member.duration;
+ : addMediaTime({ a: member.startTime, b: member.duration });
const snapResult = resolveTimelineSnap({
- targetTime: baseEdgeTime + rawDeltaTicks,
+ targetTime: addMediaTime({ a: baseEdgeTime, b: rawDeltaTime }),
snapPoints,
maxSnapDistance,
});
@@ -282,28 +311,30 @@ export class ResizeController {
) {
closestSnapDistance = snapResult.snapDistance;
closestSnapPoint = snapResult.snapPoint;
- deltaTicks = snapResult.snappedTime - baseEdgeTime;
+ deltaTime = subMediaTime({ a: snapResult.snappedTime, b: baseEdgeTime });
}
}
this.config.onSnapPointChange?.(closestSnapPoint);
- return deltaTicks;
+ return deltaTime;
}
private handleMouseMove({ clientX }: MouseEvent): void {
if (this.session.kind !== "active") return;
const session = this.session;
- const rawDeltaTicks = Math.round(
- ((clientX - session.startX) /
- (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
- TICKS_PER_SECOND,
- );
- const deltaTicks = this.snappedDelta(session, rawDeltaTicks);
+ const rawDeltaTime = mediaTime({
+ ticks: Math.round(
+ ((clientX - session.startX) /
+ (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
+ TICKS_PER_SECOND,
+ ),
+ });
+ const deltaTime = this.snappedDelta(session, rawDeltaTime);
const result = computeGroupResize({
members: session.members,
side: session.side,
- deltaTicks,
+ deltaTime,
fps: session.fps,
});
diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts
index f32c855e..67dc6e96 100644
--- a/apps/web/src/timeline/controllers/seek-controller.ts
+++ b/apps/web/src/timeline/controllers/seek-controller.ts
@@ -1,7 +1,7 @@
import type { MouseEvent as ReactMouseEvent } from "react";
-import { snappedSeekTime, type FrameRate } from "opencut-wasm";
+import type { FrameRate } from "opencut-wasm";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } from "@/wasm";
type SeekSource = "ruler" | "tracks";
@@ -17,7 +17,7 @@ type Session = { kind: "idle" } | PendingSeekSession;
export interface SeekConfig {
zoomLevel: number;
- duration: number;
+ duration: MediaTime;
isSelecting: boolean;
getPlayheadEl: () => HTMLDivElement | null;
getTrackLabelsEl: () => HTMLDivElement | null;
@@ -25,11 +25,11 @@ export interface SeekConfig {
getTracksScrollEl: () => HTMLDivElement | null;
getActiveProjectFps: () => FrameRate | null;
clearSelectedElements: () => void;
- seek: (time: number) => void;
+ seek: (time: MediaTime) => void;
setTimelineViewState: (viewState: {
zoomLevel: number;
scrollLeft: number;
- playheadTime: number;
+ playheadTime: MediaTime;
}) => void;
}
@@ -46,8 +46,8 @@ function pixelToTime({
clientX: number;
scrollContainer: HTMLDivElement;
zoomLevel: number;
- duration: number;
-}): number {
+ duration: MediaTime;
+}): MediaTime {
const rect = scrollContainer.getBoundingClientRect();
const mouseX = clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
@@ -60,7 +60,7 @@ function pixelToTime({
),
);
- return Math.round(rawTimeSeconds * TICKS_PER_SECOND);
+ return mediaTime({ ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND) });
}
function isClickGesture({
@@ -193,11 +193,11 @@ export class SeekController {
const fps = this.config.getActiveProjectFps();
const time =
fps != null
- ? (snappedSeekTime({
+ ? snapSeekMediaTime({
time: rawTime,
duration: this.config.duration,
- rate: fps,
- }) ?? rawTime)
+ fps,
+ })
: rawTime;
this.config.seek(time);
diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts
index 8e0af4fe..c5090e16 100644
--- a/apps/web/src/timeline/controllers/zoom-controller.ts
+++ b/apps/web/src/timeline/controllers/zoom-controller.ts
@@ -3,6 +3,7 @@ import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/i
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
import { TIMELINE_ZOOM_MAX } from "@/timeline/scale";
import { zoomToSlider } from "@/timeline/zoom-utils";
+import type { MediaTime } from "@/wasm";
type ZoomUpdater = number | ((prev: number) => number);
@@ -11,12 +12,12 @@ export interface ZoomConfig {
getContainerEl: () => HTMLDivElement | null;
getTracksScrollEl: () => HTMLDivElement | null;
getRulerScrollEl: () => HTMLDivElement | null;
- getCurrentPlayheadTime: () => number;
- seek: (time: number) => void;
+ getCurrentPlayheadTime: () => MediaTime;
+ seek: (time: MediaTime) => void;
setTimelineViewState: (viewState: {
zoomLevel: number;
scrollLeft: number;
- playheadTime: number;
+ playheadTime: MediaTime;
}) => void;
}
@@ -249,7 +250,7 @@ export class ZoomController {
return () => observer.disconnect();
}
- restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void {
+ restoreInitialPlayheadIfNeeded(initialPlayheadTime?: MediaTime): void {
if (initialPlayheadTime === undefined) return;
if (this.hasRestoredPlayhead) return;
diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts
index 2dec3186..0f28af56 100644
--- a/apps/web/src/timeline/creation.ts
+++ b/apps/web/src/timeline/creation.ts
@@ -1,20 +1,17 @@
-import { TICKS_PER_SECOND } from "@/wasm/ticks";
+import { mediaTime, mediaTimeFromSeconds, TICKS_PER_SECOND } from "@/wasm";
-export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND;
+export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({
+ ticks: 5 * TICKS_PER_SECOND,
+});
-/**
- * Resolves the duration in ticks that a newly created timeline element
- * should have, given an optional source duration in seconds.
- *
- * Falls back to {@link DEFAULT_NEW_ELEMENT_DURATION} when the source has
- * no known duration (e.g. stickers, graphics, text, or media whose
- * probe failed).
- */
export function toElementDurationTicks({
seconds,
}: {
seconds: number | null | undefined;
-}): number {
- if (seconds == null) return DEFAULT_NEW_ELEMENT_DURATION;
- return Math.round(seconds * TICKS_PER_SECOND);
+}) {
+ if (seconds == null) {
+ return DEFAULT_NEW_ELEMENT_DURATION;
+ }
+
+ return mediaTimeFromSeconds({ seconds });
}
diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts
index 06639e01..3b47b4f4 100644
--- a/apps/web/src/timeline/defaults.ts
+++ b/apps/web/src/timeline/defaults.ts
@@ -1,6 +1,7 @@
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import type { TTimelineViewState } from "@/project/types";
import type { BlendMode, Transform } from "@/rendering";
+import { ZERO_MEDIA_TIME } from "@/wasm";
import type { TextElement } from "./types";
const defaultTransform: Transform = {
@@ -42,9 +43,9 @@ const defaultTextElement: Omit<TextElement, "id"> = {
letterSpacing: defaultTextLetterSpacing,
lineHeight: defaultTextLineHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
+ startTime: ZERO_MEDIA_TIME,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
transform: {
...defaultTransform,
position: { ...defaultTransform.position },
@@ -55,7 +56,7 @@ const defaultTextElement: Omit<TextElement, "id"> = {
const defaultTimelineViewState: TTimelineViewState = {
zoomLevel: 1,
scrollLeft: 0,
- playheadTime: 0,
+ playheadTime: ZERO_MEDIA_TIME,
};
export const DEFAULTS = {
diff --git a/apps/web/src/timeline/drag-utils.ts b/apps/web/src/timeline/drag-utils.ts
index a731d7a0..ad8d8001 100644
--- a/apps/web/src/timeline/drag-utils.ts
+++ b/apps/web/src/timeline/drag-utils.ts
@@ -1,5 +1,5 @@
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
export function getMouseTimeFromClientX({
clientX,
@@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({
containerRect: DOMRect;
zoomLevel: number;
scrollLeft: number;
-}): number {
+}): MediaTime {
const mouseX = clientX - containerRect.left + scrollLeft;
const seconds = Math.max(
0,
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
);
- return Math.round(seconds * TICKS_PER_SECOND);
+ return mediaTime({
+ ticks: Math.round(seconds * TICKS_PER_SECOND),
+ });
}
diff --git a/apps/web/src/timeline/element-snap-source.ts b/apps/web/src/timeline/element-snap-source.ts
index ec1e5f65..26aa86f6 100644
--- a/apps/web/src/timeline/element-snap-source.ts
+++ b/apps/web/src/timeline/element-snap-source.ts
@@ -1,5 +1,6 @@
import type { SceneTracks } from "@/timeline";
import type { SnapPoint } from "@/timeline/snapping";
+import { addMediaTime } from "@/wasm";
export function getElementEdgeSnapPoints({
tracks,
@@ -25,7 +26,7 @@ export function getElementEdgeSnapPoints({
trackId: track.id,
},
{
- time: element.startTime + element.duration,
+ time: addMediaTime({ a: element.startTime, b: element.duration }),
type: "element-end",
elementId: element.id,
trackId: track.id,
diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts
index 676bc823..cddbf6fd 100644
--- a/apps/web/src/timeline/element-utils.ts
+++ b/apps/web/src/timeline/element-utils.ts
@@ -29,6 +29,7 @@ import { buildDefaultEffectInstance } from "@/effects";
import { buildDefaultGraphicInstance } from "@/graphics";
import type { ParamValues } from "@/params";
import { capitalizeFirstLetter } from "@/utils/string";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export function canElementHaveAudio(
element: TimelineElement,
@@ -107,7 +108,7 @@ export function buildTextElement({
startTime,
}: {
raw: Partial<Omit<TextElement, "type" | "id">>;
- startTime: number;
+ startTime: MediaTime;
}): CreateTimelineElement {
const t = raw as Partial<TextElement>;
@@ -117,8 +118,8 @@ export function buildTextElement({
content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
color: t.color ?? DEFAULTS.text.element.color,
@@ -141,8 +142,8 @@ export function buildEffectElement({
duration,
}: {
effectType: string;
- startTime: number;
- duration?: number;
+ startTime: MediaTime;
+ duration?: MediaTime;
}): CreateEffectElement {
const instance = buildDefaultEffectInstance({ effectType });
return {
@@ -152,8 +153,8 @@ export function buildEffectElement({
params: instance.params,
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
};
}
@@ -166,7 +167,7 @@ export function buildStickerElement({
}: {
stickerId: string;
name?: string;
- startTime: number;
+ startTime: MediaTime;
intrinsicWidth?: number;
intrinsicHeight?: number;
}): CreateStickerElement {
@@ -180,8 +181,8 @@ export function buildStickerElement({
intrinsicHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
@@ -199,7 +200,7 @@ export function buildGraphicElement({
}: {
definitionId: string;
name?: string;
- startTime: number;
+ startTime: MediaTime;
params?: Partial<ParamValues>;
}): CreateGraphicElement {
const instance = buildDefaultGraphicInstance({ definitionId });
@@ -210,8 +211,8 @@ export function buildGraphicElement({
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
@@ -229,8 +230,8 @@ function buildVideoElement({
}: {
mediaId: string;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
}): CreateVideoElement {
return {
type: "video",
@@ -238,8 +239,8 @@ function buildVideoElement({
name,
duration,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration,
muted: false,
isSourceAudioEnabled: true,
@@ -262,8 +263,8 @@ function buildImageElement({
}: {
mediaId: string;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
}): CreateImageElement {
return {
type: "image",
@@ -271,8 +272,8 @@ function buildImageElement({
name,
duration,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
hidden: false,
transform: {
...DEFAULTS.element.transform,
@@ -292,8 +293,8 @@ function buildUploadAudioElement({
}: {
mediaId: string;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
buffer?: AudioBuffer;
}): CreateUploadAudioElement {
const element: CreateUploadAudioElement = {
@@ -303,8 +304,8 @@ function buildUploadAudioElement({
name,
duration,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration,
volume: DEFAULTS.element.volume,
muted: false,
@@ -326,8 +327,8 @@ export function buildElementFromMedia({
mediaId: string;
mediaType: MediaType;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
buffer?: AudioBuffer;
}): CreateTimelineElement {
switch (mediaType) {
@@ -355,8 +356,8 @@ export function buildLibraryAudioElement({
}: {
sourceUrl: string;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
buffer?: AudioBuffer;
}): CreateLibraryAudioElement {
const element: CreateLibraryAudioElement = {
@@ -366,8 +367,8 @@ export function buildLibraryAudioElement({
name,
duration,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration,
volume: DEFAULTS.element.volume,
muted: false,
diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts
index 00f39d55..3e19e3e3 100644
--- a/apps/web/src/timeline/group-move/build-group.ts
+++ b/apps/web/src/timeline/group-move/build-group.ts
@@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline";
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
import type { GroupMember, MoveGroup } from "./types";
import { getTrackPlacementById } from "./track-placement";
+import { subMediaTime } from "@/wasm";
export function buildMoveGroup({
anchorRef,
@@ -59,7 +60,10 @@ export function buildMoveGroup({
elementId: element.id,
elementType: element.type,
duration: element.duration,
- timeOffset: element.startTime - anchorElement.startTime,
+ timeOffset: subMediaTime({
+ a: element.startTime,
+ b: anchorElement.startTime,
+ }),
trackSection: placement.section,
sectionIndex: placement.sectionIndex,
displayIndex: placement.displayIndex,
diff --git a/apps/web/src/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts
index 60940315..8307aad0 100644
--- a/apps/web/src/timeline/group-move/resolve-move.ts
+++ b/apps/web/src/timeline/group-move/resolve-move.ts
@@ -12,6 +12,13 @@ import {
getTrackPlacementByDisplayIndex,
getTrackPlacementById,
} from "./track-placement";
+import {
+ addMediaTime,
+ maxMediaTime,
+ type MediaTime,
+ subMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
type GroupMoveTarget =
| {
@@ -32,7 +39,7 @@ export function resolveGroupMove({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
target: GroupMoveTarget;
}): GroupMoveResult | null {
if (target.kind === "newTracks") {
@@ -61,7 +68,7 @@ function resolveExistingTrackMove({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
anchorTargetTrackId: string;
}): GroupMoveResult | null {
const anchorTargetPlacement = getTrackPlacementById({
@@ -93,7 +100,10 @@ function resolveExistingTrackMove({
targetTrackId:
targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
elementId: member.elementId,
- newStartTime: clampedAnchorStartTime + member.timeOffset,
+ newStartTime: addMediaTime({
+ a: clampedAnchorStartTime,
+ b: member.timeOffset,
+ }),
}));
if (!canApplyMovesToExistingTracks({ tracks, moves })) {
@@ -119,7 +129,7 @@ function resolveNewTrackMove({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
anchorInsertIndex: number;
newTrackIds: string[];
}): GroupMoveResult | null {
@@ -173,7 +183,10 @@ function resolveNewTrackMove({
sourceTrackId: member.trackId,
targetTrackId: newTrackIds[memberIndex],
elementId: member.elementId,
- newStartTime: clampedAnchorStartTime + member.timeOffset,
+ newStartTime: addMediaTime({
+ a: clampedAnchorStartTime,
+ b: member.timeOffset,
+ }),
}));
return {
@@ -335,17 +348,26 @@ function clampAnchorStartTime({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
targetTrackIdsByElementId: Map<string, string>;
-}): number {
- const minimumAnchorStartTime = Math.max(
- 0,
- ...group.members.map((member) => -member.timeOffset),
- );
- let clampedAnchorStartTime = Math.max(
- minimumAnchorStartTime,
- anchorStartTime,
+}): MediaTime {
+ const minimumAnchorStartTime = group.members.reduce(
+ (minimumStartTime, member) =>
+ member.timeOffset < ZERO_MEDIA_TIME
+ ? maxMediaTime({
+ a: minimumStartTime,
+ b: subMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: member.timeOffset,
+ }),
+ })
+ : minimumStartTime,
+ ZERO_MEDIA_TIME,
);
+ let clampedAnchorStartTime =
+ anchorStartTime < minimumAnchorStartTime
+ ? minimumAnchorStartTime
+ : anchorStartTime;
const memberOnMainTrack = group.members.find(
(member) =>
@@ -358,11 +380,13 @@ function clampAnchorStartTime({
const movingElementIds = new Set(
group.members.map((member) => member.elementId),
);
- const requestedMainStartTime =
- clampedAnchorStartTime + memberOnMainTrack.timeOffset;
+ const requestedMainStartTime = addMediaTime({
+ a: clampedAnchorStartTime,
+ b: memberOnMainTrack.timeOffset,
+ });
const earliestStationaryMainStartTime = tracks.main.elements
.filter((element) => !movingElementIds.has(element.id))
- .reduce<number | null>((earliestStartTime, element) => {
+ .reduce<MediaTime | null>((earliestStartTime, element) => {
if (earliestStartTime == null || element.startTime < earliestStartTime) {
return element.startTime;
}
@@ -373,10 +397,13 @@ function clampAnchorStartTime({
earliestStationaryMainStartTime == null ||
requestedMainStartTime <= earliestStationaryMainStartTime
) {
- clampedAnchorStartTime = Math.max(
- minimumAnchorStartTime,
- -memberOnMainTrack.timeOffset,
- );
+ clampedAnchorStartTime = maxMediaTime({
+ a: minimumAnchorStartTime,
+ b: subMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: memberOnMainTrack.timeOffset,
+ }),
+ });
}
return clampedAnchorStartTime;
@@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({
const sourceElement = sourceElements.get(move.elementId);
return {
startTime: move.newStartTime,
- duration: sourceElement?.duration ?? 0,
+ duration: sourceElement?.duration ?? ZERO_MEDIA_TIME,
};
});
if (hasOverlappingTimeSpans({ timeSpans })) {
diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts
index 7f431e6e..7aaf4423 100644
--- a/apps/web/src/timeline/group-move/snap.ts
+++ b/apps/web/src/timeline/group-move/snap.ts
@@ -9,6 +9,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import type { MoveGroup } from "./types";
+import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm";
export function snapGroupEdges({
group,
@@ -18,12 +19,12 @@ export function snapGroupEdges({
zoomLevel,
}: {
group: MoveGroup;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
tracks: SceneTracks;
- playheadTime: number;
+ playheadTime: MediaTime;
zoomLevel: number;
}): {
- snappedAnchorStartTime: number;
+ snappedAnchorStartTime: MediaTime;
snapPoint: SnapPoint | null;
} {
const excludeElementIds = new Set(
@@ -47,7 +48,10 @@ export function snapGroupEdges({
let snapPoint: SnapPoint | null = null;
for (const member of group.members) {
- const memberStartTime = anchorStartTime + member.timeOffset;
+ const memberStartTime = addMediaTime({
+ a: anchorStartTime,
+ b: member.timeOffset,
+ });
const memberStartSnap = resolveTimelineSnap({
targetTime: memberStartTime,
snapPoints,
@@ -58,12 +62,18 @@ export function snapGroupEdges({
memberStartSnap.snapDistance < closestSnapDistance
) {
closestSnapDistance = memberStartSnap.snapDistance;
- snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
+ snappedAnchorStartTime = subMediaTime({
+ a: memberStartSnap.snappedTime,
+ b: member.timeOffset,
+ });
snapPoint = memberStartSnap.snapPoint;
}
const memberEndSnap = resolveTimelineSnap({
- targetTime: memberStartTime + member.duration,
+ targetTime: addMediaTime({
+ a: memberStartTime,
+ b: member.duration,
+ }),
snapPoints,
maxSnapDistance,
});
@@ -72,8 +82,13 @@ export function snapGroupEdges({
memberEndSnap.snapDistance < closestSnapDistance
) {
closestSnapDistance = memberEndSnap.snapDistance;
- snappedAnchorStartTime =
- memberEndSnap.snappedTime - member.duration - member.timeOffset;
+ snappedAnchorStartTime = subMediaTime({
+ a: subMediaTime({
+ a: memberEndSnap.snappedTime,
+ b: member.duration,
+ }),
+ b: member.timeOffset,
+ });
snapPoint = memberEndSnap.snapPoint;
}
}
diff --git a/apps/web/src/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts
index daf9c6e9..f97b698c 100644
--- a/apps/web/src/timeline/group-move/types.ts
+++ b/apps/web/src/timeline/group-move/types.ts
@@ -1,11 +1,12 @@
import type { ElementRef, ElementType, TrackType } from "@/timeline";
+import type { MediaTime } from "@/wasm";
export type GroupTrackSection = "overlay" | "main" | "audio";
export interface GroupMember extends ElementRef {
elementType: ElementType;
- duration: number;
- timeOffset: number;
+ duration: MediaTime;
+ timeOffset: MediaTime;
trackSection: GroupTrackSection;
sectionIndex: number;
displayIndex: number;
@@ -26,7 +27,7 @@ export interface PlannedElementMove {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
- newStartTime: number;
+ newStartTime: MediaTime;
}
export interface GroupMoveResult {
diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts
index d22b6891..ed335997 100644
--- a/apps/web/src/timeline/group-resize/compute-resize.ts
+++ b/apps/web/src/timeline/group-resize/compute-resize.ts
@@ -1,9 +1,20 @@
-import { roundToFrame } from "opencut-wasm";
import {
getSourceSpanAtClipTime,
getTimelineDurationForSourceSpan,
} from "@/retime";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ clampMediaTime,
+ maxMediaTime,
+ type MediaTime,
+ mediaTime,
+ minMediaTime,
+ roundFrameTicks,
+ roundMediaTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import type {
ComputeGroupResizeArgs,
GroupResizeMember,
@@ -15,63 +26,88 @@ import type {
export function computeGroupResize({
members,
side,
- deltaTicks,
+ deltaTime,
fps,
}: ComputeGroupResizeArgs): GroupResizeResult {
- const minDuration = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
- const minimumDeltaTicks = Math.max(
- ...members.map((member) =>
- getMinimumAllowedDeltaTicks({
- member,
- side,
- minDuration,
- }),
- ),
- );
- const maximumDeltaTicks = Math.min(
- ...members.map((member) =>
- getMaximumAllowedDeltaTicks({
+ if (members.length === 0) {
+ return { deltaTime: ZERO_MEDIA_TIME, updates: [] };
+ }
+
+ const minDuration = mediaTime({
+ ticks: Math.round((TICKS_PER_SECOND * fps.denominator) / fps.numerator),
+ });
+ let minimumDeltaTime = getMinimumAllowedDeltaTime({
+ member: members[0],
+ side,
+ minDuration,
+ });
+ let maximumDeltaTime = getMaximumAllowedDeltaTime({
+ member: members[0],
+ side,
+ minDuration,
+ });
+
+ for (const member of members.slice(1)) {
+ minimumDeltaTime = maxMediaTime({
+ a: minimumDeltaTime,
+ b: getMinimumAllowedDeltaTime({
member,
side,
minDuration,
}),
- ),
- );
- const clampedDeltaTicks =
- minimumDeltaTicks > maximumDeltaTicks
- ? minimumDeltaTicks
- : Math.min(maximumDeltaTicks, Math.max(minimumDeltaTicks, deltaTicks));
+ });
+ const memberMaximum = getMaximumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+ });
+ if (memberMaximum !== null) {
+ maximumDeltaTime =
+ maximumDeltaTime === null
+ ? memberMaximum
+ : minMediaTime({ a: maximumDeltaTime, b: memberMaximum });
+ }
+ }
+
+ const clampedDeltaTime =
+ maximumDeltaTime === null
+ ? maxMediaTime({ a: minimumDeltaTime, b: deltaTime })
+ : clampMediaTime({
+ time: deltaTime,
+ min: minimumDeltaTime,
+ max: maximumDeltaTime,
+ });
// Snap the drag delta to a frame exactly once, then derive every patch
// field from that single snapped value. This keeps the invariant
// `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
// delta is added on one side of the element and removed from the other,
- // so the rounding cancels by construction. Rounding each field
- // independently would break this — the individual rounds don't compose
- // when `sourceDuration` isn't frame-aligned.
- const snappedDeltaTicks =
- roundToFrame({ time: clampedDeltaTicks, rate: fps }) ?? clampedDeltaTicks;
+ // so the rounding cancels by construction. Per-field rounding (the old
+ // approach) couldn't preserve this because the individual rounds don't
+ // compose when `sourceDuration` isn't frame-aligned.
+ const snappedDeltaTime = mediaTime({
+ ticks: roundFrameTicks({ ticks: clampedDeltaTime, fps }),
+ });
// Re-clamp after rounding. Bounds derived from other elements are
// frame-aligned, so this is normally a no-op; at the source-extent limit
// the bound may not be frame-aligned, and honouring the bound takes
// precedence over frame alignment (you can't extend past real content).
- const finalDeltaTicks =
- minimumDeltaTicks > maximumDeltaTicks
- ? minimumDeltaTicks
- : Math.min(
- maximumDeltaTicks,
- Math.max(minimumDeltaTicks, snappedDeltaTicks),
- );
+ const finalDeltaTime =
+ maximumDeltaTime === null
+ ? maxMediaTime({ a: minimumDeltaTime, b: snappedDeltaTime })
+ : clampMediaTime({
+ time: snappedDeltaTime,
+ min: minimumDeltaTime,
+ max: maximumDeltaTime,
+ });
return {
- deltaTicks: Object.is(finalDeltaTicks, -0) ? 0 : finalDeltaTicks,
+ deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime,
updates: members.map((member) =>
buildResizeUpdate({
member,
side,
- deltaTicks: finalDeltaTicks,
+ deltaTime: finalDeltaTime,
}),
),
};
@@ -80,27 +116,30 @@ export function computeGroupResize({
function buildResizeUpdate({
member,
side,
- deltaTicks,
+ deltaTime,
}: {
member: GroupResizeMember;
side: ResizeSide;
- deltaTicks: number;
+ deltaTime: MediaTime;
}): GroupResizeUpdate {
const sourceDelta = getSourceDeltaForClipDelta({
member,
- clipDelta: deltaTicks,
+ clipDelta: deltaTime,
});
if (side === "left") {
return {
trackId: member.trackId,
elementId: member.elementId,
- patch: {
- trimStart: Math.max(0, member.trimStart + sourceDelta),
- trimEnd: member.trimEnd,
- startTime: member.startTime + deltaTicks,
- duration: member.duration - deltaTicks,
- },
+ patch: {
+ trimStart: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: addMediaTime({ a: member.trimStart, b: sourceDelta }),
+ }),
+ trimEnd: member.trimEnd,
+ startTime: addMediaTime({ a: member.startTime, b: deltaTime }),
+ duration: subMediaTime({ a: member.duration, b: deltaTime }),
+ },
};
}
@@ -109,72 +148,95 @@ function buildResizeUpdate({
elementId: member.elementId,
patch: {
trimStart: member.trimStart,
- trimEnd: Math.max(0, member.trimEnd - sourceDelta),
+ trimEnd: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({ a: member.trimEnd, b: sourceDelta }),
+ }),
startTime: member.startTime,
- duration: member.duration + deltaTicks,
+ duration: addMediaTime({ a: member.duration, b: deltaTime }),
},
};
}
-function getMinimumAllowedDeltaTicks({
+function getMinimumAllowedDeltaTime({
member,
side,
minDuration,
}: {
member: GroupResizeMember;
side: ResizeSide;
- minDuration: number;
-}): number {
+ minDuration: MediaTime;
+}): MediaTime {
if (side === "right") {
- return minDuration - member.duration;
+ return subMediaTime({ a: minDuration, b: member.duration });
}
- const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
- ? member.leftNeighborBound - member.startTime
- : -member.startTime;
+ const leftNeighborFloor =
+ member.leftNeighborBound !== null
+ ? subMediaTime({ a: member.leftNeighborBound, b: member.startTime })
+ : subMediaTime({ a: ZERO_MEDIA_TIME, b: member.startTime });
if (member.sourceDuration == null) {
return leftNeighborFloor;
}
- const maximumSourceExtension =
- getDurationForVisibleSourceSpan({
+ const maximumSourceExtension = subMediaTime({
+ a: getDurationForVisibleSourceSpan({
member,
- sourceSpan:
- getVisibleSourceSpanForDuration({
+ sourceSpan: addMediaTime({
+ a: getVisibleSourceSpanForDuration({
member,
duration: member.duration,
- }) + member.trimStart,
- }) - member.duration;
- return Math.max(leftNeighborFloor, -maximumSourceExtension);
+ }),
+ b: member.trimStart,
+ }),
+ }),
+ b: member.duration,
+ });
+ return maxMediaTime({
+ a: leftNeighborFloor,
+ b: subMediaTime({ a: ZERO_MEDIA_TIME, b: maximumSourceExtension }),
+ });
}
-function getMaximumAllowedDeltaTicks({
+function getMaximumAllowedDeltaTime({
member,
side,
minDuration,
}: {
member: GroupResizeMember;
side: ResizeSide;
- minDuration: number;
-}): number {
+ minDuration: MediaTime;
+}): MediaTime | null {
if (side === "left") {
- return member.duration - minDuration;
+ return subMediaTime({ a: member.duration, b: minDuration });
}
- const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
- ? member.rightNeighborBound - (member.startTime + member.duration)
- : Infinity;
+ const rightNeighborCeiling =
+ member.rightNeighborBound === null
+ ? null
+ : subMediaTime({
+ a: member.rightNeighborBound,
+ b: addMediaTime({ a: member.startTime, b: member.duration }),
+ });
if (member.sourceDuration == null) {
return rightNeighborCeiling;
}
- const maximumVisibleSourceSpan =
- getSourceDuration({ member }) - member.trimStart;
+ const maximumVisibleSourceSpan = subMediaTime({
+ a: getSourceDuration({ member }),
+ b: member.trimStart,
+ });
const maximumDuration = getDurationForVisibleSourceSpan({
member,
sourceSpan: maximumVisibleSourceSpan,
});
- return Math.min(rightNeighborCeiling, maximumDuration - member.duration);
+ const sourceDurationCeiling = subMediaTime({
+ a: maximumDuration,
+ b: member.duration,
+ });
+ return rightNeighborCeiling === null
+ ? sourceDurationCeiling
+ : minMediaTime({ a: rightNeighborCeiling, b: sourceDurationCeiling });
}
function getSourceDeltaForClipDelta({
@@ -182,21 +244,23 @@ function getSourceDeltaForClipDelta({
clipDelta,
}: {
member: GroupResizeMember;
- clipDelta: number;
-}): number {
+ clipDelta: MediaTime;
+}): MediaTime {
if (!member.retime) {
return clipDelta;
}
- return clipDelta >= 0
- ? getSourceSpanAtClipTime({
- clipTime: clipDelta,
- retime: member.retime,
- })
- : -getSourceSpanAtClipTime({
- clipTime: Math.abs(clipDelta),
- retime: member.retime,
- });
+ const sourceDelta =
+ clipDelta >= 0
+ ? getSourceSpanAtClipTime({
+ clipTime: clipDelta,
+ retime: member.retime,
+ })
+ : -getSourceSpanAtClipTime({
+ clipTime: Math.abs(clipDelta),
+ retime: member.retime,
+ });
+ return roundMediaTime({ time: sourceDelta });
}
function getVisibleSourceSpanForDuration({
@@ -204,15 +268,17 @@ function getVisibleSourceSpanForDuration({
duration,
}: {
member: GroupResizeMember;
- duration: number;
-}): number {
+ duration: MediaTime;
+}): MediaTime {
if (!member.retime) {
return duration;
}
- return getSourceSpanAtClipTime({
- clipTime: duration,
- retime: member.retime,
+ return roundMediaTime({
+ time: getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime: member.retime,
+ }),
});
}
@@ -221,29 +287,33 @@ function getDurationForVisibleSourceSpan({
sourceSpan,
}: {
member: GroupResizeMember;
- sourceSpan: number;
-}): number {
+ sourceSpan: MediaTime;
+}): MediaTime {
if (!member.retime) {
return sourceSpan;
}
- return getTimelineDurationForSourceSpan({
- sourceSpan,
- retime: member.retime,
+ return roundMediaTime({
+ time: getTimelineDurationForSourceSpan({
+ sourceSpan,
+ retime: member.retime,
+ }),
});
}
-function getSourceDuration({ member }: { member: GroupResizeMember }): number {
- if (typeof member.sourceDuration === "number") {
+function getSourceDuration({ member }: { member: GroupResizeMember }): MediaTime {
+ if (member.sourceDuration != null) {
return member.sourceDuration;
}
- return (
- member.trimStart +
- getVisibleSourceSpanForDuration({
+ return addMediaTime({
+ a: addMediaTime({
+ a: member.trimStart,
+ b: getVisibleSourceSpanForDuration({
member,
duration: member.duration,
- }) +
- member.trimEnd
- );
+ }),
+ }),
+ b: member.trimEnd,
+ });
}
diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts
index 511358ba..5c0f77f6 100644
--- a/apps/web/src/timeline/group-resize/types.ts
+++ b/apps/web/src/timeline/group-resize/types.ts
@@ -1,36 +1,37 @@
import type { FrameRate } from "opencut-wasm";
import type { ElementRef, RetimeConfig } from "@/timeline/types";
+import type { MediaTime } from "@/wasm";
export type ResizeSide = "left" | "right";
export interface GroupResizeMember extends ElementRef {
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- sourceDuration?: number;
+ startTime: MediaTime;
+ duration: MediaTime;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ sourceDuration?: MediaTime;
retime?: RetimeConfig;
- leftNeighborBound: number;
- rightNeighborBound: number;
+ leftNeighborBound: MediaTime | null;
+ rightNeighborBound: MediaTime | null;
}
export interface GroupResizeUpdate extends ElementRef {
patch: {
- trimStart: number;
- trimEnd: number;
- startTime: number;
- duration: number;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ startTime: MediaTime;
+ duration: MediaTime;
};
}
export interface GroupResizeResult {
- deltaTicks: number;
+ deltaTime: MediaTime;
updates: GroupResizeUpdate[];
}
export interface ComputeGroupResizeArgs {
members: GroupResizeMember[];
side: ResizeSide;
- deltaTicks: number;
+ deltaTime: MediaTime;
fps: FrameRate;
}
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
index 5a1ff1f0..ea03cb5c 100644
--- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
@@ -8,6 +8,7 @@ import {
type KeyframeDragState,
} from "@/timeline/controllers/keyframe-drag-controller";
import type { TimelineElement } from "@/timeline";
+import type { MediaTime } from "@/wasm";
export type { KeyframeDragState };
@@ -18,7 +19,7 @@ export function useKeyframeDrag({
}: {
zoomLevel: number;
element: TimelineElement;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
}) {
const editor = useEditor();
const {
@@ -40,7 +41,7 @@ export function useKeyframeDrag({
toggleKeyframeSelection,
selectKeyframeRange,
executeCommand: (command) => editor.command.execute({ command }),
- seek: (args) => editor.playback.seek(args),
+ seek: ({ time }) => editor.playback.seek({ time }),
getTotalDuration: () => editor.timeline.getTotalDuration(),
};
diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
index 12bfe76a..15ef3575 100644
--- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
@@ -7,6 +7,7 @@ import {
PlayheadController,
type PlayheadConfig,
} from "@/timeline/controllers/playhead-controller";
+import type { MediaTime } from "@/wasm";
interface UseTimelinePlayheadProps {
zoomLevel: number;
@@ -44,8 +45,14 @@ export function useTimelinePlayhead({
seek: (time) => editor.playback.seek({ time }),
setScrubbing: (scrubbing) =>
editor.playback.setScrubbing({ isScrubbing: scrubbing }),
- setTimelineViewState: (viewState) =>
- editor.project.setTimelineViewState({ viewState }),
+ setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft,
+ playheadTime,
+ },
+ }),
};
const configRef = useRef(config);
configRef.current = config;
@@ -68,7 +75,7 @@ export function useTimelinePlayhead({
// Playback events → update playhead position and auto-scroll during playback.
useEffect(() => {
- const handler = (time: number) => ctrl.handlePlaybackUpdate(time);
+ const handler = (time: MediaTime) => ctrl.handlePlaybackUpdate(time);
ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
const unsubscribeUpdate = editor.playback.onUpdate(handler);
const unsubscribeSeek = editor.playback.onSeek(handler);
diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts
index 46363fb6..3579bd75 100644
--- a/apps/web/src/timeline/hooks/use-timeline-resize.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts
@@ -10,6 +10,7 @@ import {
} from "@/timeline/controllers/resize-controller";
import type { ResizeSide } from "@/timeline/group-resize";
import type { SnapPoint } from "@/timeline/snapping";
+import type { TimelineElement } from "@/timeline";
export type { ResizeSide };
@@ -41,7 +42,7 @@ export function useTimelineResize({
updates: updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
- updates: patch,
+ updates: patch as Partial<TimelineElement>,
})),
}),
commitElements: (updates) =>
@@ -49,7 +50,7 @@ export function useTimelineResize({
updates: updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
- patch,
+ patch: patch as Partial<TimelineElement>,
})),
}),
onSnapPointChange,
diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts
index 56f68ed6..bf033dae 100644
--- a/apps/web/src/timeline/hooks/use-timeline-seek.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts
@@ -4,6 +4,7 @@ import {
SeekController,
type SeekConfig,
} from "@/timeline/controllers/seek-controller";
+import type { MediaTime } from "@/wasm";
interface UseTimelineSeekProps {
playheadRef: RefObject<HTMLDivElement | null>;
@@ -11,10 +12,10 @@ interface UseTimelineSeekProps {
rulerScrollRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
- duration: number;
+ duration: MediaTime;
isSelecting: boolean;
clearSelectedElements: () => void;
- seek: (time: number) => void;
+ seek: (time: MediaTime) => void;
}
export function useTimelineSeek({
@@ -40,8 +41,14 @@ export function useTimelineSeek({
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
clearSelectedElements,
seek,
- setTimelineViewState: (viewState) =>
- editor.project.setTimelineViewState({ viewState }),
+ setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft,
+ playheadTime,
+ },
+ }),
};
const configRef = useRef(config);
diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
index a8825a37..3020504f 100644
--- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
@@ -12,13 +12,14 @@ import {
ZoomController,
type ZoomConfig,
} from "@/timeline/controllers/zoom-controller";
+import type { MediaTime } from "@/wasm";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement | null>;
minZoom?: number;
initialZoom?: number;
initialScrollLeft?: number;
- initialPlayheadTime?: number;
+ initialPlayheadTime?: MediaTime;
tracksScrollRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>;
}
@@ -47,8 +48,14 @@ export function useTimelineZoom({
getRulerScrollEl: () => rulerScrollRef.current,
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
seek: (time) => editor.playback.seek({ time }),
- setTimelineViewState: (viewState) =>
- editor.project.setTimelineViewState({ viewState }),
+ setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft,
+ playheadTime,
+ },
+ }),
};
const configRef = useRef(config);
configRef.current = config;
diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts
index 5ee079b1..3775e006 100644
--- a/apps/web/src/timeline/index.ts
+++ b/apps/web/src/timeline/index.ts
@@ -1,29 +1,32 @@
-import type { SceneTracks } from "./types";
-
-export * from "./types";
-export * from "./drag";
-export * from "./track-capabilities";
-export * from "./track-element-update";
-export * from "./element-utils";
-export * from "./audio-separation";
-export * from "./zoom-utils";
-export * from "./ruler-utils";
-export * from "./pixel-utils";
-
-export function calculateTotalDuration({
- tracks,
-}: {
- tracks: SceneTracks;
-}): number {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- if (orderedTracks.length === 0) return 0;
-
- const trackEndTimes = orderedTracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd = element.startTime + element.duration;
- return Math.max(maxEnd, elementEnd);
- }, 0),
- );
-
- return Math.max(...trackEndTimes, 0);
-}
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
+import type { SceneTracks } from "./types";
+
+export * from "./types";
+export * from "./drag";
+export * from "./track-capabilities";
+export * from "./track-element-update";
+export * from "./element-utils";
+export * from "./audio-separation";
+export * from "./zoom-utils";
+export * from "./ruler-utils";
+export * from "./pixel-utils";
+
+export function calculateTotalDuration({
+ tracks,
+}: {
+ tracks: SceneTracks;
+}): MediaTime {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ if (orderedTracks.length === 0) return ZERO_MEDIA_TIME;
+
+ let maxEnd: MediaTime = ZERO_MEDIA_TIME;
+ for (const track of orderedTracks) {
+ for (const element of track.elements) {
+ // `startTime + duration` is integer-by-construction (both `MediaTime`),
+ // so the cast re-establishes the brand without runtime work.
+ const elementEnd = (element.startTime + element.duration) as MediaTime;
+ if (elementEnd > maxEnd) maxEnd = elementEnd;
+ }
+ }
+ return maxEnd;
+}
diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
index ebbfe578..44f0a1fe 100644
--- a/apps/web/src/timeline/placement/__tests__/resolve.test.ts
+++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
@@ -14,6 +14,7 @@ import type {
} from "@/timeline";
import type { Transform } from "@/rendering";
import { resolveTrackPlacement } from "@/timeline/placement";
+import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
function buildTransform(): Transform {
return {
@@ -67,10 +68,10 @@ function buildElement({
id,
type: "audio",
name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
volume: 1,
sourceType: "upload",
mediaId: `media-${id}`,
@@ -80,10 +81,10 @@ function buildElement({
id,
type: "graphic",
name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
definitionId: `graphic-${id}`,
params: {},
transform: buildTransform(),
@@ -94,10 +95,10 @@ function buildElement({
id,
type: "text",
name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
content: id,
fontSize: 32,
fontFamily: "sans-serif",
@@ -118,10 +119,10 @@ function buildElement({
id,
type: "video",
name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
mediaId: `media-${id}`,
transform: buildTransform(),
opacity: 1,
@@ -224,7 +225,11 @@ function buildTimeSpan({
duration: number;
excludeElementId?: string;
}) {
- return { startTime, duration, excludeElementId };
+ return {
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ excludeElementId,
+ };
}
function buildSceneTracks({
diff --git a/apps/web/src/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts
index ab2ab7bd..36632226 100644
--- a/apps/web/src/timeline/placement/main-track.ts
+++ b/apps/web/src/timeline/placement/main-track.ts
@@ -1,4 +1,5 @@
import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export const MAIN_TRACK_NAME = "Main Track";
@@ -31,9 +32,9 @@ export function enforceMainTrackStart({
}: {
tracks: SceneTracks;
targetTrackId: string;
- requestedStartTime: number;
+ requestedStartTime: MediaTime;
excludeElementId?: string;
-}): number {
+}): MediaTime {
if (tracks.main.id !== targetTrackId) {
return requestedStartTime;
}
@@ -43,11 +44,11 @@ export function enforceMainTrackStart({
excludeElementId,
});
if (!earliestElement) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
if (requestedStartTime <= earliestElement.startTime) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
return requestedStartTime;
diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts
index 26275392..680cb0bb 100644
--- a/apps/web/src/timeline/placement/resolve.ts
+++ b/apps/web/src/timeline/placement/resolve.ts
@@ -13,6 +13,7 @@ import type {
PlacementSubject,
PlacementTimeSpan,
} from "./types";
+import { ZERO_MEDIA_TIME } from "@/wasm";
type ResolveTrackPlacementParams = PlacementSubject & {
tracks: SceneTracks;
@@ -32,7 +33,7 @@ function buildExistingTrackResult({
timeSpans: PlacementTimeSpan[];
}): PlacementResult {
const firstSpan = timeSpans[0];
- const requestedStartTime = firstSpan?.startTime ?? 0;
+ const requestedStartTime = firstSpan?.startTime ?? ZERO_MEDIA_TIME;
const adjustedStartTime = enforceMainTrackStart({
tracks,
targetTrackId: track.id,
diff --git a/apps/web/src/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts
index 6847ab36..748f34d7 100644
--- a/apps/web/src/timeline/placement/types.ts
+++ b/apps/web/src/timeline/placement/types.ts
@@ -1,8 +1,9 @@
import type { ElementType, TrackType } from "@/timeline";
+import type { MediaTime } from "@/wasm";
export interface PlacementTimeSpan {
- startTime: number;
- duration: number;
+ startTime: MediaTime;
+ duration: MediaTime;
excludeElementId?: string;
}
@@ -29,7 +30,7 @@ export type PlacementResult =
trackId: string;
trackIndex: number;
trackType: TrackType;
- adjustedStartTime?: number;
+ adjustedStartTime?: MediaTime;
}
| {
kind: "newTrack";
diff --git a/apps/web/src/timeline/playhead-snap-source.ts b/apps/web/src/timeline/playhead-snap-source.ts
index 88e21eba..0b41e17b 100644
--- a/apps/web/src/timeline/playhead-snap-source.ts
+++ b/apps/web/src/timeline/playhead-snap-source.ts
@@ -1,9 +1,10 @@
import type { SnapPoint } from "@/timeline/snapping";
+import type { MediaTime } from "@/wasm";
export function getPlayheadSnapPoints({
playheadTime,
}: {
- playheadTime: number;
+ playheadTime: MediaTime;
}): SnapPoint[] {
return [{ time: playheadTime, type: "playhead" }];
}
diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts
index d48c943c..8aa1b6b5 100644
--- a/apps/web/src/timeline/scenes.ts
+++ b/apps/web/src/timeline/scenes.ts
@@ -2,6 +2,7 @@ import type { TScene } from "@/timeline";
import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/timeline";
import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null;
@@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({
scenes,
}: {
scenes: TScene[];
-}): number {
+}): MediaTime {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene?.tracks) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
return calculateTotalDuration({ tracks: mainScene.tracks });
diff --git a/apps/web/src/timeline/snapping/resolve.ts b/apps/web/src/timeline/snapping/resolve.ts
index 2f00d081..dcbc670d 100644
--- a/apps/web/src/timeline/snapping/resolve.ts
+++ b/apps/web/src/timeline/snapping/resolve.ts
@@ -1,11 +1,12 @@
import type { SnapPoint, SnapResult } from "./types";
+import type { MediaTime } from "@/wasm";
export function resolveTimelineSnap({
targetTime,
snapPoints,
maxSnapDistance,
}: {
- targetTime: number;
+ targetTime: MediaTime;
snapPoints: SnapPoint[];
maxSnapDistance: number;
}): SnapResult {
diff --git a/apps/web/src/timeline/snapping/types.ts b/apps/web/src/timeline/snapping/types.ts
index 2a6d0903..7bffd84f 100644
--- a/apps/web/src/timeline/snapping/types.ts
+++ b/apps/web/src/timeline/snapping/types.ts
@@ -1,3 +1,5 @@
+import type { MediaTime } from "@/wasm";
+
export type SnapPointType =
| "element-start"
| "element-end"
@@ -6,14 +8,14 @@ export type SnapPointType =
| "keyframe";
export interface SnapPoint {
- time: number;
+ time: MediaTime;
type: SnapPointType;
elementId?: string;
trackId?: string;
}
export interface SnapResult {
- snappedTime: number;
+ snappedTime: MediaTime;
snapPoint: SnapPoint | null;
snapDistance: number;
}
diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts
index 338da9d9..99df3128 100644
--- a/apps/web/src/timeline/types.ts
+++ b/apps/web/src/timeline/types.ts
@@ -1,317 +1,332 @@
-import type { ElementAnimations } from "@/animation/types";
-import type { Effect } from "@/effects/types";
-import type { Mask } from "@/masks/types";
-import type { ParamValues } from "@/params";
-import type { BlendMode, Transform } from "@/rendering";
-
-export type ElementRef = {
- trackId: string;
- elementId: string;
-};
-
-export interface Bookmark {
- time: number;
- note?: string;
- color?: string;
- duration?: number;
-}
-
-export interface TScene {
- id: string;
- name: string;
- isMain: boolean;
- tracks: SceneTracks;
- bookmarks: Bookmark[];
- createdAt: Date;
- updatedAt: Date;
-}
-
-export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
-
-interface BaseTrack {
- id: string;
- name: string;
-}
-
-export interface VideoTrack extends BaseTrack {
- type: "video";
- elements: (VideoElement | ImageElement)[];
- muted: boolean;
- hidden: boolean;
-}
-
-export interface TextTrack extends BaseTrack {
- type: "text";
- elements: TextElement[];
- hidden: boolean;
-}
-
-export interface AudioTrack extends BaseTrack {
- type: "audio";
- elements: AudioElement[];
- muted: boolean;
-}
-
-export interface GraphicTrack extends BaseTrack {
- type: "graphic";
- elements: (StickerElement | GraphicElement)[];
- hidden: boolean;
-}
-
-export interface EffectTrack extends BaseTrack {
- type: "effect";
- elements: EffectElement[];
- hidden: boolean;
-}
-
-export type TimelineTrack =
- | VideoTrack
- | TextTrack
- | AudioTrack
- | GraphicTrack
- | EffectTrack;
-
-export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
-
-export interface SceneTracks {
- overlay: OverlayTrack[];
- main: VideoTrack;
- audio: AudioTrack[];
-}
-
-export interface RetimeConfig {
- rate: number;
- maintainPitch?: boolean;
-}
-
-interface BaseAudioElement extends BaseTimelineElement {
- type: "audio";
- volume: number;
- muted?: boolean;
- buffer?: AudioBuffer;
- retime?: RetimeConfig;
-}
-
-export interface UploadAudioElement extends BaseAudioElement {
- sourceType: "upload";
- mediaId: string;
-}
-
-export interface LibraryAudioElement extends BaseAudioElement {
- sourceType: "library";
- sourceUrl: string;
-}
-
-export type AudioElement = UploadAudioElement | LibraryAudioElement;
-
-interface BaseTimelineElement {
- id: string;
- name: string;
- duration: number;
- startTime: number;
- trimStart: number;
- trimEnd: number;
- sourceDuration?: number;
- animations?: ElementAnimations;
-}
-
-export interface VideoElement extends BaseTimelineElement {
- type: "video";
- mediaId: string;
- volume?: number;
- muted?: boolean;
- isSourceAudioEnabled?: boolean;
- hidden?: boolean;
- retime?: RetimeConfig;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface ImageElement extends BaseTimelineElement {
- type: "image";
- mediaId: string;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface TextBackground {
- enabled: boolean;
- color: string;
- cornerRadius?: number;
- paddingX?: number;
- paddingY?: number;
- offsetX?: number;
- offsetY?: number;
-}
-
-export interface TextElement extends BaseTimelineElement {
- type: "text";
- content: string;
- fontSize: number;
- fontFamily: string;
- color: string;
- background: TextBackground;
- textAlign: "left" | "center" | "right";
- fontWeight: "normal" | "bold";
- fontStyle: "normal" | "italic";
- textDecoration: "none" | "underline" | "line-through";
- letterSpacing?: number;
- lineHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface StickerElement extends BaseTimelineElement {
- type: "sticker";
- stickerId: string;
- /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
- intrinsicWidth?: number;
- intrinsicHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface GraphicElement extends BaseTimelineElement {
- type: "graphic";
- definitionId: string;
- params: ParamValues;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface EffectElement extends BaseTimelineElement {
- type: "effect";
- effectType: string;
- params: ParamValues;
-}
-
-export type ElementUpdatePatch =
- | { transform: Transform }
- | { opacity: number }
- | { volume: number };
-
-export type TimelineElement =
- | AudioElement
- | VideoElement
- | ImageElement
- | TextElement
- | StickerElement
- | GraphicElement
- | EffectElement;
-
-export type ElementType = TimelineElement["type"];
-
-function elementTypes<T extends ElementType[]>(...types: T): T {
- return types;
-}
-
-export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
-
-export type MaskableElement = Extract<
- TimelineElement,
- { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
->;
-
-export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
-
-export type RetimableElement = Extract<
- TimelineElement,
- { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
->;
-
-export const VISUAL_ELEMENT_TYPES = elementTypes(
- "video",
- "image",
- "text",
- "sticker",
- "graphic",
-);
-
-export type VisualElement = Extract<
- TimelineElement,
- { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
->;
-
-export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
-export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
-export type CreateAudioElement =
- | CreateUploadAudioElement
- | CreateLibraryAudioElement;
-export type CreateVideoElement = Omit<VideoElement, "id">;
-export type CreateImageElement = Omit<ImageElement, "id">;
-export type CreateTextElement = Omit<TextElement, "id">;
-export type CreateStickerElement = Omit<StickerElement, "id">;
-export type CreateGraphicElement = Omit<GraphicElement, "id">;
-export type CreateEffectElement = Omit<EffectElement, "id">;
-export type CreateTimelineElement =
- | CreateAudioElement
- | CreateVideoElement
- | CreateImageElement
- | CreateTextElement
- | CreateStickerElement
- | CreateGraphicElement
- | CreateEffectElement;
-
-export type ElementDragView =
- | { readonly kind: "idle" }
- | {
- readonly kind: "dragging";
- readonly anchorElementId: string;
- readonly trackId: string;
- readonly memberTimeOffsets: ReadonlyMap<string, number>;
- readonly startMouseX: number;
- readonly startMouseY: number;
- readonly startElementTime: number;
- readonly clickOffsetTime: number;
- readonly currentTime: number;
- readonly currentMouseX: number;
- readonly currentMouseY: number;
- readonly dropTarget: DropTarget | null;
- };
-
-export interface DropTarget {
- trackIndex: number;
- isNewTrack: boolean;
- insertPosition: "above" | "below" | null;
- xPosition: number;
- targetElement: { elementId: string; trackId: string } | null;
-}
-
-export interface ComputeDropTargetParams {
- elementType: ElementType;
- mouseX: number;
- mouseY: number;
- tracks: SceneTracks;
- playheadTime: number;
- isExternalDrop: boolean;
- elementDuration: number;
- pixelsPerSecond: number;
- zoomLevel: number;
- verticalDragDirection?: "up" | "down" | null;
- startTimeOverride?: number;
- excludeElementId?: string;
- targetElementTypes?: string[];
-}
-
-export interface ClipboardItem {
- trackId: string;
- trackType: TrackType;
- element: CreateTimelineElement;
-}
+import type { ElementAnimations } from "@/animation/types";
+import type { Effect } from "@/effects/types";
+import type { Mask } from "@/masks/types";
+import type { ParamValues } from "@/params";
+import type { BlendMode, Transform } from "@/rendering";
+import type { MediaTime } from "@/wasm";
+
+export type ElementRef = {
+ trackId: string;
+ elementId: string;
+};
+
+export interface Bookmark {
+ time: MediaTime;
+ note?: string;
+ color?: string;
+ duration?: MediaTime;
+}
+
+export interface TScene {
+ id: string;
+ name: string;
+ isMain: boolean;
+ tracks: SceneTracks;
+ bookmarks: Bookmark[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
+
+interface BaseTrack {
+ id: string;
+ name: string;
+}
+
+export interface VideoTrack extends BaseTrack {
+ type: "video";
+ elements: (VideoElement | ImageElement)[];
+ muted: boolean;
+ hidden: boolean;
+}
+
+export interface TextTrack extends BaseTrack {
+ type: "text";
+ elements: TextElement[];
+ hidden: boolean;
+}
+
+export interface AudioTrack extends BaseTrack {
+ type: "audio";
+ elements: AudioElement[];
+ muted: boolean;
+}
+
+export interface GraphicTrack extends BaseTrack {
+ type: "graphic";
+ elements: (StickerElement | GraphicElement)[];
+ hidden: boolean;
+}
+
+export interface EffectTrack extends BaseTrack {
+ type: "effect";
+ elements: EffectElement[];
+ hidden: boolean;
+}
+
+export type TimelineTrack =
+ | VideoTrack
+ | TextTrack
+ | AudioTrack
+ | GraphicTrack
+ | EffectTrack;
+
+export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
+
+export interface SceneTracks {
+ overlay: OverlayTrack[];
+ main: VideoTrack;
+ audio: AudioTrack[];
+}
+
+export interface RetimeConfig {
+ rate: number;
+ maintainPitch?: boolean;
+}
+
+interface BaseAudioElement extends BaseTimelineElement {
+ type: "audio";
+ volume: number;
+ muted?: boolean;
+ buffer?: AudioBuffer;
+ retime?: RetimeConfig;
+}
+
+export interface UploadAudioElement extends BaseAudioElement {
+ sourceType: "upload";
+ mediaId: string;
+}
+
+export interface LibraryAudioElement extends BaseAudioElement {
+ sourceType: "library";
+ sourceUrl: string;
+}
+
+export type AudioElement = UploadAudioElement | LibraryAudioElement;
+
+interface BaseTimelineElement {
+ id: string;
+ name: string;
+ duration: MediaTime;
+ startTime: MediaTime;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ sourceDuration?: MediaTime;
+ animations?: ElementAnimations;
+}
+
+export interface VideoElement extends BaseTimelineElement {
+ type: "video";
+ mediaId: string;
+ volume?: number;
+ muted?: boolean;
+ isSourceAudioEnabled?: boolean;
+ hidden?: boolean;
+ retime?: RetimeConfig;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface ImageElement extends BaseTimelineElement {
+ type: "image";
+ mediaId: string;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface TextBackground {
+ enabled: boolean;
+ color: string;
+ cornerRadius?: number;
+ paddingX?: number;
+ paddingY?: number;
+ offsetX?: number;
+ offsetY?: number;
+}
+
+export interface TextElement extends BaseTimelineElement {
+ type: "text";
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ color: string;
+ background: TextBackground;
+ textAlign: "left" | "center" | "right";
+ fontWeight: "normal" | "bold";
+ fontStyle: "normal" | "italic";
+ textDecoration: "none" | "underline" | "line-through";
+ letterSpacing?: number;
+ lineHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface StickerElement extends BaseTimelineElement {
+ type: "sticker";
+ stickerId: string;
+ /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface GraphicElement extends BaseTimelineElement {
+ type: "graphic";
+ definitionId: string;
+ params: ParamValues;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface EffectElement extends BaseTimelineElement {
+ type: "effect";
+ effectType: string;
+ params: ParamValues;
+}
+
+export type ElementUpdatePatch =
+ | { transform: Transform }
+ | { opacity: number }
+ | { volume: number };
+
+export type TimelineElement =
+ | AudioElement
+ | VideoElement
+ | ImageElement
+ | TextElement
+ | StickerElement
+ | GraphicElement
+ | EffectElement;
+
+export type ElementType = TimelineElement["type"];
+
+function elementTypes<T extends ElementType[]>(...types: T): T {
+ return types;
+}
+
+export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
+
+export type MaskableElement = Extract<
+ TimelineElement,
+ { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
+
+export type RetimableElement = Extract<
+ TimelineElement,
+ { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const VISUAL_ELEMENT_TYPES = elementTypes(
+ "video",
+ "image",
+ "text",
+ "sticker",
+ "graphic",
+);
+
+export type VisualElement = Extract<
+ TimelineElement,
+ { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
+>;
+
+export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
+export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
+export type CreateAudioElement =
+ | CreateUploadAudioElement
+ | CreateLibraryAudioElement;
+export type CreateVideoElement = Omit<VideoElement, "id">;
+export type CreateImageElement = Omit<ImageElement, "id">;
+export type CreateTextElement = Omit<TextElement, "id">;
+export type CreateStickerElement = Omit<StickerElement, "id">;
+export type CreateGraphicElement = Omit<GraphicElement, "id">;
+export type CreateEffectElement = Omit<EffectElement, "id">;
+export type CreateTimelineElement =
+ | CreateAudioElement
+ | CreateVideoElement
+ | CreateImageElement
+ | CreateTextElement
+ | CreateStickerElement
+ | CreateGraphicElement
+ | CreateEffectElement;
+
+export interface ElementDragState {
+ isDragging: boolean;
+ elementId: string | null;
+ dragElementIds: string[];
+ dragTimeOffsets: Record<string, MediaTime>;
+ trackId: string | null;
+ startMouseX: number;
+ startMouseY: number;
+ startElementTime: MediaTime;
+ clickOffsetTime: MediaTime;
+ currentTime: MediaTime;
+ currentMouseY: number;
+}
+
+export type ElementDragView =
+ | { readonly kind: "idle" }
+ | {
+ readonly kind: "dragging";
+ readonly anchorElementId: string;
+ readonly trackId: string;
+ readonly memberTimeOffsets: ReadonlyMap<string, MediaTime>;
+ readonly startMouseX: number;
+ readonly startMouseY: number;
+ readonly startElementTime: MediaTime;
+ readonly clickOffsetTime: MediaTime;
+ readonly currentTime: MediaTime;
+ readonly currentMouseX: number;
+ readonly currentMouseY: number;
+ readonly dropTarget: DropTarget | null;
+ };
+
+export interface DropTarget {
+ trackIndex: number;
+ isNewTrack: boolean;
+ insertPosition: "above" | "below" | null;
+ xPosition: MediaTime;
+ targetElement: { elementId: string; trackId: string } | null;
+}
+
+export interface ComputeDropTargetParams {
+ elementType: ElementType;
+ mouseX: number;
+ mouseY: number;
+ tracks: SceneTracks;
+ playheadTime: MediaTime;
+ isExternalDrop: boolean;
+ elementDuration: MediaTime;
+ pixelsPerSecond: number;
+ zoomLevel: number;
+ verticalDragDirection?: "up" | "down" | null;
+ startTimeOverride?: MediaTime;
+ excludeElementId?: string;
+ targetElementTypes?: string[];
+}
+
+export interface ClipboardItem {
+ trackId: string;
+ trackType: TrackType;
+ element: CreateTimelineElement;
+}
diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts
index beacf897..7ab129e9 100644
--- a/apps/web/src/timeline/update-pipeline.ts
+++ b/apps/web/src/timeline/update-pipeline.ts
@@ -6,6 +6,7 @@ import {
} from "@/retime";
import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline";
import { isRetimableElement } from "@/timeline";
+import { ZERO_MEDIA_TIME, roundMediaTime } from "@/wasm";
type ElementUpdateField = keyof TimelineElement | string;
@@ -61,9 +62,11 @@ const deriveRules: ElementUpdateRule[] = [
0,
sourceDuration - element.trimStart - element.trimEnd,
);
- const nextDuration = getTimelineDurationForSourceSpan({
- sourceSpan: visibleSourceSpan,
- retime: nextRetime,
+ const nextDuration = roundMediaTime({
+ time: getTimelineDurationForSourceSpan({
+ sourceSpan: visibleSourceSpan,
+ retime: nextRetime,
+ }),
});
return {
@@ -94,7 +97,10 @@ const enforceRules: ElementUpdateRule[] = [
{
triggers: ["startTime"],
apply: ({ element, context }) => {
- const requestedStartTime = Math.max(0, element.startTime);
+ const requestedStartTime =
+ element.startTime < ZERO_MEDIA_TIME
+ ? ZERO_MEDIA_TIME
+ : element.startTime;
if (context.trackId !== context.tracks.main.id) {
return {
element: {
@@ -118,7 +124,7 @@ const enforceRules: ElementUpdateRule[] = [
...element,
startTime:
!earliestElement || requestedStartTime <= earliestElement.startTime
- ? 0
+ ? ZERO_MEDIA_TIME
: requestedStartTime,
},
};
diff --git a/apps/web/src/wasm/index.ts b/apps/web/src/wasm/index.ts
index 60031a0e..c979b620 100644
--- a/apps/web/src/wasm/index.ts
+++ b/apps/web/src/wasm/index.ts
@@ -1 +1 @@
-export * from "./ticks";
+export * from "./media-time";
diff --git a/apps/web/src/wasm/media-time.ts b/apps/web/src/wasm/media-time.ts
new file mode 100644
index 00000000..82f5378d
--- /dev/null
+++ b/apps/web/src/wasm/media-time.ts
@@ -0,0 +1,190 @@
+import {
+ lastFrameTime as _lastFrameTime,
+ parseTimecode as _parseTimecode,
+ roundToFrame as _roundToFrame,
+ snappedSeekTime as _snappedSeekTime,
+ TICKS_PER_SECOND as _TICKS_PER_SECOND,
+ mediaTimeFromSeconds as _mediaTimeFromSeconds,
+ mediaTimeToSeconds as _mediaTimeToSeconds,
+ type FrameRate,
+ type TimeCodeFormat,
+} from "opencut-wasm";
+
+/**
+ * Integer-tick time. Mirrors `MediaTime(i64)` in `rust/crates/time/src/media_time.rs`.
+ *
+ * `opencut-wasm` exposes `MediaTime` as a bare `number` alias because tsify
+ * collapses tuple structs. The brand here is the TS-side discipline that
+ * recovers the invariant: a `MediaTime` is an integer count of ticks, and the
+ * only legal way to construct one from a fractional `number` is `roundMediaTime`
+ * (or `mediaTimeFromSeconds`, which rounds inside the wasm boundary).
+ *
+ * Reading is free — `MediaTime` is assignable to `number`. Writing is gated —
+ * a bare `number` is not assignable to `MediaTime`.
+ */
+export type MediaTime = number & { readonly __mediaTime: unique symbol };
+
+export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
+
+export const ZERO_MEDIA_TIME = 0 as MediaTime;
+
+/**
+ * Construct a `MediaTime` from a known-integer tick count. Asserts in dev that
+ * the value is actually an integer; cast in release. Use `roundMediaTime` when
+ * the input may be fractional.
+ */
+export function mediaTime({ ticks }: { ticks: number }): MediaTime {
+ if (process.env.NODE_ENV !== "production" && !Number.isInteger(ticks)) {
+ throw new Error(
+ `mediaTime() requires an integer tick count, got ${ticks}. Use roundMediaTime() for fractional values.`,
+ );
+ }
+ return ticks as MediaTime;
+}
+
+/**
+ * Project a fractional value onto the integer-tick lattice.
+ *
+ * Rounds half away from zero (`-1.5 → -2`, `1.5 → 2`) and normalises `-0` to
+ * `0`. The away-from-zero rule matches Rust's `.round()` and avoids the
+ * `Math.round(-0.5) === -0` quirk that propagates `-0` into stored data.
+ */
+export function roundMediaTime({ time }: { time: number }): MediaTime {
+ const roundedMagnitude = Math.round(Math.abs(time));
+ if (roundedMagnitude === 0) {
+ return 0 as MediaTime;
+ }
+ return (time < 0 ? -roundedMagnitude : roundedMagnitude) as MediaTime;
+}
+
+export function mediaTimeFromSeconds({
+ seconds,
+}: {
+ seconds: number;
+}): MediaTime {
+ const result = _mediaTimeFromSeconds({ seconds });
+ if (result === undefined) {
+ throw new Error(
+ `mediaTimeFromSeconds: rust returned undefined for seconds=${seconds}`,
+ );
+ }
+ return result as MediaTime;
+}
+
+export function mediaTimeToSeconds({ time }: { time: MediaTime }): number {
+ return _mediaTimeToSeconds({ time });
+}
+
+/**
+ * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too;
+ * the cast is a no-op at runtime, only re-establishes the brand for the type
+ * system.
+ */
+export function addMediaTime({
+ a,
+ b,
+}: {
+ a: MediaTime;
+ b: MediaTime;
+}): MediaTime {
+ return (a + b) as MediaTime;
+}
+
+export function subMediaTime({
+ a,
+ b,
+}: {
+ a: MediaTime;
+ b: MediaTime;
+}): MediaTime {
+ return (a - b) as MediaTime;
+}
+
+export function maxMediaTime({
+ a,
+ b,
+}: {
+ a: MediaTime;
+ b: MediaTime;
+}): MediaTime {
+ return (a > b ? a : b) as MediaTime;
+}
+
+export function minMediaTime({
+ a,
+ b,
+}: {
+ a: MediaTime;
+ b: MediaTime;
+}): MediaTime {
+ return (a < b ? a : b) as MediaTime;
+}
+
+export function clampMediaTime({
+ time,
+ min,
+ max,
+}: {
+ time: MediaTime;
+ min: MediaTime;
+ max: MediaTime;
+}): MediaTime {
+ if (time < min) return min;
+ if (time > max) return max;
+ return time;
+}
+
+export function roundFrameTime({
+ time,
+ fps,
+}: {
+ time: MediaTime;
+ fps: FrameRate;
+}): MediaTime {
+ return (_roundToFrame({ time, rate: fps }) ?? time) as MediaTime;
+}
+
+export function roundFrameTicks({
+ ticks,
+ fps,
+}: {
+ ticks: number;
+ fps: FrameRate;
+}): number {
+ return _roundToFrame({ time: ticks, rate: fps }) ?? ticks;
+}
+
+export function snapSeekMediaTime({
+ time,
+ duration,
+ fps,
+}: {
+ time: MediaTime;
+ duration: MediaTime;
+ fps: FrameRate;
+}): MediaTime {
+ return (_snappedSeekTime({ time, duration, rate: fps }) ?? time) as MediaTime;
+}
+
+export function lastFrameMediaTime({
+ duration,
+ fps,
+}: {
+ duration: MediaTime;
+ fps: FrameRate;
+}): MediaTime {
+ return (_lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime;
+}
+
+export function parseMediaTimecode({
+ timeCode,
+ format,
+ fps,
+}: {
+ timeCode: string;
+ format: TimeCodeFormat;
+ fps: FrameRate;
+}): MediaTime | null {
+ const parsedTime = _parseTimecode({ timeCode, format, rate: fps });
+ return parsedTime == null ? null : (parsedTime as MediaTime);
+}
diff --git a/apps/web/src/wasm/ticks.ts b/apps/web/src/wasm/ticks.ts
deleted file mode 100644
index ababe6b3..00000000
--- a/apps/web/src/wasm/ticks.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
-
-export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
diff --git a/bun.lock b/bun.lock
index cb36d56e..e2198a3e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,7 @@
"@types/react-dom": "^19.2.3",
"better-auth": "^1.4.15",
"next": "^16.1.3",
+ "opencut-wasm": "^0.2.10",
},
"devDependencies": {
"turbo": "^2.8.20",
@@ -54,7 +55,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
- "opencut-wasm": "^0.2.8",
+ "opencut-wasm": "^0.2.10",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
@@ -785,7 +786,7 @@
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="],
- "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
+ "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
@@ -875,7 +876,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
- "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
+ "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -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=="],
- "opencut-wasm": ["opencut-wasm@0.2.8", "", {}, "sha512-R1cXB4HuTC5RdbGRYD0Q2n9rWQWDRB7KMwGJnbjrhnnwxBbR5XDu/3tL7hlzb/RZ7VZcaX5OGHZ1QgL6ex/2cQ=="],
+ "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=="],
diff --git a/package.json b/package.json
index 22504650..941dd0ad 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,8 @@
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"better-auth": "^1.4.15",
- "next": "^16.1.3"
+ "next": "^16.1.3",
+ "opencut-wasm": "^0.2.10"
},
"devDependencies": {
"turbo": "^2.8.20",
diff --git a/random_gibberish.txt b/random_gibberish.txt
new file mode 100644
index 00000000..6a8c5579
--- /dev/null
+++ b/random_gibberish.txt
@@ -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
diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml
index 3fbfe5ba..0f2bfc56 100644
--- a/rust/wasm/Cargo.toml
+++ b/rust/wasm/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "opencut-wasm"
-version = "0.2.9"
+version = "0.2.10"
edition = "2024"
description = "Shared video editor logic compiled to WebAssembly"
repository = "https://github.com/opencut/opencut"