Compare commits

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

Some files were not shown because too many files have changed in this diff Show More