diff options
102 files changed, 3654 insertions, 1939 deletions
@@ -3820,7 +3820,7 @@ dependencies = [ [[package]] name = "opencut-wasm" -version = "0.2.9" +version = "0.2.10" dependencies = [ "bridge", "compositor", @@ -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)
-
----
-
-
+ +| | | +| --- | ---------------------------------------------------------------------- | +| | 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(); @@ -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" |
