Compare commits

..
Author SHA1 Message Date
Maze Winther b305917ac1 fix: surface degraded GPU renderer notices 2026-04-25 18:14:48 +02:00
262 changed files with 3234 additions and 3256 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",
+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",
@@ -1,241 +0,0 @@
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
@@ -1,83 +0,0 @@
import { snapToStep } from "@/utils/math";
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { DynamicAnimationPathValue, NumericSpec } from "./types";
export function getAnimationParamValueKind({
param,
}: {
param: ParamDefinition;
}): "number" | "color" | "discrete" {
if (param.type === "number") {
return "number";
}
if (param.type === "color") {
return "color";
}
return "discrete";
}
export function getAnimationParamDefaultInterpolation({
param,
}: {
param: ParamDefinition;
}): "linear" | "hold" {
return param.type === "number" || param.type === "color" ? "linear" : "hold";
}
export function getAnimationParamNumericRange({
param,
}: {
param: ParamDefinition;
}): NumericSpec | undefined {
if (param.type !== "number") {
return undefined;
}
return {
min: param.min,
max: param.max,
step: param.step,
};
}
/**
* `coerceAnimationParamValue` accepts `unknown` rather than a narrow
* `DynamicAnimationPathValue` because it doubles as a runtime gate for
* untrusted inputs (persisted state, paste payloads, programmatic updates).
* The caller does not need to pre-validate; null means "this value cannot live
* on this param".
*/
export function coerceAnimationParamValue({
param,
value,
}: {
param: ParamDefinition;
value: unknown;
}): DynamicAnimationPathValue | null {
if (param.type === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
const steppedValue = snapToStep({ value, step: param.step });
const minValue = param.min;
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
if (param.type === "color") {
return typeof value === "string" ? value : null;
}
if (param.type === "boolean") {
return typeof value === "boolean" ? value : null;
}
if (typeof value !== "string") {
return null;
}
return param.options.some((option) => option.value === value) ? value : null;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ScalarAnimationKey } from "@/model/decorations/animations";
import type { ScalarAnimationKey } from "@/animation/types";
const BEZIER_SOLVE_ITERATIONS = 20;
+14 -1
View File
@@ -1,5 +1,18 @@
import { converter, formatHex, formatHex8, parse } from "culori";
import type { AnimationBindingComponent, AnimationBindingInstance, AnimationBindingKind, AnimationBindingOfKind, AnimationPath, AnimationValue, ColorAnimationBinding, DiscreteAnimationBinding, DiscreteValue, NumberAnimationBinding, Vector2AnimationBinding, VectorValue } from "@/model/decorations/animations";
import type {
AnimationBindingComponent,
AnimationBindingOfKind,
AnimationBindingInstance,
AnimationBindingKind,
ColorAnimationBinding,
DiscreteAnimationBinding,
NumberAnimationBinding,
AnimationPath,
AnimationValue,
DiscreteValue,
Vector2AnimationBinding,
VectorValue,
} from "@/animation/types";
import { clamp } from "@/utils/math";
interface LinearRgba {
+5 -1
View File
@@ -2,7 +2,11 @@ import {
getDefaultLeftHandle,
getDefaultRightHandle,
} from "@/animation/bezier";
import type { CurveHandle, NormalizedCubicBezier, ScalarAnimationKey } from "@/model/decorations/animations";
import type {
CurveHandle,
NormalizedCubicBezier,
ScalarAnimationKey,
} from "@/animation/types";
const VALUE_EPSILON = 1e-6;
+11 -10
View File
@@ -1,5 +1,9 @@
import type { EffectParamPath, ElementAnimations } from "@/model/decorations/animations";
import type { ParamValues } from "@/model/decorations/param-values";
import type { ParamValues } from "@/params";
import type { Effect } from "@/effects/types";
import type {
ElementAnimations,
EffectParamPath,
} from "@/animation/types";
import { removeElementKeyframe } from "./keyframes";
import { resolveAnimationPathValueAtTime } from "./resolve";
@@ -52,26 +56,23 @@ export function parseEffectParamPath({
}
export function resolveEffectParamsAtTime({
effectId,
params,
effect,
animations,
localTime,
}: {
effectId: string;
params: ParamValues;
effect: Effect;
animations: ElementAnimations | undefined;
localTime: number;
}): ParamValues {
const safeLocalTime = Math.max(0, localTime);
const resolved: ParamValues = {};
for (const [paramKey, staticValue] of Object.entries(params)) {
const path = buildEffectParamPath({ effectId, paramKey });
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
resolved[paramKey] = animations?.bindings[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
localTime: safeLocalTime,
localTime,
fallbackValue: staticValue,
})
: staticValue;
+8 -2
View File
@@ -1,5 +1,11 @@
import type { AnimationBindingInstance, AnimationPath, ElementAnimations, ScalarAnimationChannel } from "@/model/decorations/animations";
import type { ScalarGraphChannel, ScalarGraphKeyframeContext } from "@/animation/types";
import type {
AnimationBindingInstance,
AnimationPath,
ElementAnimations,
ScalarAnimationChannel,
ScalarGraphChannel,
ScalarGraphKeyframeContext,
} from "@/animation/types";
export interface EditableScalarChannels {
binding: AnimationBindingInstance;
+24 -13
View File
@@ -1,5 +1,12 @@
import type { ElementAnimations, GraphicParamPath } from "@/model/decorations/animations";
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
import type {
ElementAnimations,
GraphicParamPath,
} from "@/animation/types";
import type { ParamValues } from "@/params";
import {
getGraphicDefinition,
resolveGraphicParams,
} from "@/graphics";
import { resolveAnimationPathValueAtTime } from "./resolve";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
@@ -32,29 +39,33 @@ export function parseGraphicParamPath({
}
export function resolveGraphicParamsAtTime({
params,
definitions,
animations,
element,
localTime,
}: {
params: ParamValues;
definitions: ParamDefinition[];
animations?: ElementAnimations;
element: {
definitionId: string;
params: ParamValues;
animations?: ElementAnimations;
};
localTime: number;
}): ParamValues {
const resolved: ParamValues = { ...params };
const definition = getGraphicDefinition({
definitionId: element.definitionId,
});
const baseParams = resolveGraphicParams(definition, element.params);
const resolved: ParamValues = { ...baseParams };
for (const param of definitions) {
for (const param of definition.params) {
const path = buildGraphicParamPath({ paramKey: param.key });
if (!animations?.bindings[path]) {
if (!element.animations?.bindings[path]) {
continue;
}
resolved[param.key] = resolveAnimationPathValueAtTime({
animations,
animations: element.animations,
propertyPath: path,
localTime: Math.max(0, localTime),
fallbackValue: params[param.key] ?? param.default,
fallbackValue: baseParams[param.key] ?? param.default,
});
}
+26 -4
View File
@@ -16,14 +16,31 @@ export {
setChannel,
splitAnimationsAtTime,
updateScalarKeyframeCurve,
upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
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,
@@ -58,6 +75,15 @@ export {
resolveEffectParamsAtTime,
} from "./effect-param-channel";
export {
isAnimationPath,
coerceAnimationValueForParam,
resolveAnimationTarget,
getParamValueKind,
getParamDefaultInterpolation,
type AnimationPathDescriptor,
} from "./target-resolver";
export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
@@ -68,7 +94,3 @@ export {
type EasingMode,
getEasingModeForKind,
} from "./binding-values";
export {
isAnimationPath,
} from "./path";
+11 -2
View File
@@ -1,11 +1,20 @@
import type { AnimationChannel, AnimationInterpolation, AnimationValue, DiscreteAnimationChannel, DiscreteValue, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType } from "@/model/decorations/animations";
import type {
AnimationChannel,
AnimationInterpolation,
AnimationValue,
DiscreteAnimationChannel,
DiscreteValue,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
} from "@/animation/types";
import { clamp } from "@/utils/math";
import {
getBezierPoint,
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
} from "./bezier";
import { clamp } from "@/utils/math";
function byTimeAscending({
leftTime,
+8 -3
View File
@@ -1,5 +1,10 @@
import type { AnimationBindingInstance, AnimationChannel, AnimationPath, ElementAnimations } from "@/model/decorations/animations";
import type { ElementKeyframe } from "@/animation/types";
import type {
AnimationBindingInstance,
AnimationChannel,
AnimationPath,
ElementAnimations,
ElementKeyframe,
} from "@/animation/types";
import {
type AnimationComponentValue,
composeAnimationValue,
@@ -8,7 +13,7 @@ import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
} from "./interpolation";
import { isAnimationPath } from "./path";
import { isAnimationPath } from "./target-resolver";
function getBindingFallbackValue({
channel,
+62 -3
View File
@@ -1,5 +1,20 @@
import type { AnimationBindingInstance, AnimationBindingKind, AnimationChannel, AnimationInterpolation, AnimationPath, AnimationValue, DiscreteAnimationChannel, DiscreteAnimationKey, ElementAnimations, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType } from "@/model/decorations/animations";
import type { ScalarCurveKeyframePatch } from "@/animation/types";
import type {
AnimationBindingInstance,
AnimationBindingKind,
AnimationChannel,
AnimationInterpolation,
AnimationPath,
AnimationPropertyPath,
AnimationValue,
DiscreteAnimationChannel,
DiscreteAnimationKey,
ElementAnimations,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/animation/types";
import { generateUUID } from "@/utils/id";
import {
cloneAnimationBinding,
createAnimationBinding,
@@ -16,7 +31,10 @@ import {
getScalarSegmentInterpolation,
normalizeChannel,
} from "./interpolation";
import { generateUUID } from "@/utils/id";
import {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
} from "./property-registry";
function isNearlySameTime({
leftTime,
@@ -509,6 +527,47 @@ export function upsertPathKeyframe({
});
}
export function upsertElementKeyframe({
animations,
propertyPath,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}): ElementAnimations | undefined {
const coercedValue = coerceAnimationValueForProperty({
propertyPath,
value,
});
if (coercedValue === null) {
return animations;
}
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return upsertPathKeyframe({
animations,
propertyPath,
time,
value: coercedValue,
interpolation,
keyframeId,
kind: propertyDefinition.kind,
defaultInterpolation: propertyDefinition.defaultInterpolation,
coerceValue: ({ value: nextValue }) =>
coerceAnimationValueForProperty({
propertyPath,
value: nextValue,
}),
});
}
export function upsertKeyframe({
channel,
time,
-22
View File
@@ -1,22 +0,0 @@
import type { AnimationPath, AnimationPropertyPath } from "@/model/decorations/animations";
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)
);
}
+6 -2
View File
@@ -1,5 +1,9 @@
import type { AnimationPropertyGroup, AnimationPropertyPath, ElementAnimations } from "@/model/decorations/animations";
import { ANIMATION_PROPERTY_GROUPS } from "@/model/decorations/animations";
import type {
AnimationPropertyGroup,
AnimationPropertyPath,
ElementAnimations,
} from "@/animation/types";
import { ANIMATION_PROPERTY_GROUPS } from "@/animation/types";
import { getKeyframeAtTime } from "./keyframe-query";
export interface GroupKeyframeRef {
@@ -1,10 +1,12 @@
import type { AnimationBindingKind, AnimationInterpolation, AnimationPropertyPath, AnimationValue, ElementAnimations, NumericSpec } from "@/model/decorations/animations";
import { upsertPathKeyframe } from "@/animation";
import { parseColorToLinearRgba } from "@/animation/binding-values";
import { isAnimationPropertyPath } from "@/animation/path";
import type {
AnimationBindingKind,
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
} from "@/animation/types";
import { parseColorToLinearRgba } from "./binding-values";
import type { TimelineElement } from "@/timeline";
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
import { snapToStep } from "@/utils/math";
import type { TimelineElement } from "@/model";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
@@ -15,6 +17,13 @@ import {
} from "@/timeline/element-utils";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
import { DEFAULTS } from "@/timeline/defaults";
import { snapToStep } from "@/utils/math";
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
export interface AnimationPropertyDefinition {
kind: AnimationBindingKind;
@@ -304,6 +313,12 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
}),
};
export function isAnimationPropertyPath(
propertyPath: string,
): propertyPath is AnimationPropertyPath {
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
}
export function getAnimationPropertyDefinition({
propertyPath,
}: {
@@ -334,7 +349,6 @@ export function getElementBaseValueForProperty({
if (!definition.supportsElement({ element })) {
return null;
}
return definition.getValue({ element });
}
@@ -352,7 +366,6 @@ export function withElementBaseValueForProperty({
if (coercedValue === null || !definition.supportsElement({ element })) {
return element;
}
return definition.setValue({ element, value: coercedValue });
}
@@ -375,44 +388,3 @@ export function coerceAnimationValueForProperty({
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.coerceValue({ value });
}
export function upsertElementKeyframe({
animations,
propertyPath,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
time: number;
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,
}),
});
}
+110 -1
View File
@@ -1,4 +1,12 @@
import type { AnimationPath, AnimationValueForPath, ElementAnimations } from "@/model/decorations/animations";
import type {
AnimationColorPropertyPath,
AnimationNumericPropertyPath,
AnimationPath,
AnimationPropertyPath,
AnimationValueForPath,
ElementAnimations,
} from "@/animation/types";
import type { Transform } from "@/rendering";
import {
type AnimationComponentValue,
composeAnimationValue,
@@ -29,6 +37,107 @@ export function getElementLocalTime({
return localTime;
}
export function resolveTransformAtTime({
baseTransform,
animations,
localTime,
}: {
baseTransform: Transform;
animations: ElementAnimations | undefined;
localTime: number;
}): Transform {
const safeLocalTime = Math.max(0, localTime);
return {
position: {
x: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.positionX",
localTime: safeLocalTime,
fallbackValue: baseTransform.position.x,
}),
y: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.positionY",
localTime: safeLocalTime,
fallbackValue: baseTransform.position.y,
}),
},
scaleX: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.scaleX",
localTime: safeLocalTime,
fallbackValue: baseTransform.scaleX,
}),
scaleY: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.scaleY",
localTime: safeLocalTime,
fallbackValue: baseTransform.scaleY,
}),
rotate: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.rotate",
localTime: safeLocalTime,
fallbackValue: baseTransform.rotate,
}),
};
}
export function resolveOpacityAtTime({
baseOpacity,
animations,
localTime,
}: {
baseOpacity: number;
animations: ElementAnimations | undefined;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath: "opacity",
localTime: Math.max(0, localTime),
fallbackValue: baseOpacity,
});
}
export function resolveNumberAtTime({
baseValue,
animations,
propertyPath,
localTime,
}: {
baseValue: number;
animations: ElementAnimations | undefined;
propertyPath: AnimationNumericPropertyPath;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseValue,
});
}
export function resolveColorAtTime({
baseColor,
animations,
propertyPath,
localTime,
}: {
baseColor: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationColorPropertyPath;
localTime: number;
}): string {
return resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime: Math.max(0, localTime),
fallbackValue: baseColor,
});
}
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
animations,
propertyPath,
@@ -1,28 +1,32 @@
import type { AnimationBindingKind, AnimationInterpolation, AnimationPath, AnimationValue, NumericSpec } from "@/model/decorations/animations";
import {
coerceAnimationParamValue,
getAnimationParamDefaultInterpolation,
getAnimationParamNumericRange,
getAnimationParamValueKind,
} from "@/animation/animated-params";
import type {
AnimationBindingKind,
AnimationInterpolation,
AnimationPath,
AnimationValue,
} from "@/animation/types";
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 "@/model/decorations/param-values";
import type { TimelineElement } from "@/model";
import type { TimelineElement } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils";
import { isAnimationPropertyPath } from "@/animation/path";
import { snapToStep } from "@/utils/math";
import {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getElementBaseValueForProperty,
isAnimationPropertyPath,
type NumericSpec,
withElementBaseValueForProperty,
} from "./animation-properties";
} from "./property-registry";
import { parseColorToLinearRgba } from "./binding-values";
export interface AnimationPathDescriptor {
kind: AnimationBindingKind;
@@ -33,16 +37,79 @@ export interface AnimationPathDescriptor {
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
}
// 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({
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({
param,
}: {
param: ParamDefinition;
}): Partial<Record<string, NumericSpec>> | undefined {
const range = getAnimationParamNumericRange({ param });
return range ? { value: range } : 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;
}
function buildGraphicParamDescriptor({
@@ -65,13 +132,13 @@ function buildGraphicParamDescriptor({
}
return {
kind: getAnimationParamValueKind({ param }),
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
numericRanges: paramNumericRanges({ param }),
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: getParamNumericRange({ param }),
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
getBaseValue: () => element.params[param.key] ?? param.default,
setBaseValue: ({ value }) => {
const coercedValue = coerceAnimationParamValue({ param, value });
const coercedValue = coerceAnimationValueForParam({ param, value });
if (coercedValue === null) {
return element;
}
@@ -113,13 +180,13 @@ function buildEffectParamDescriptor({
}
return {
kind: getAnimationParamValueKind({ param }),
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
numericRanges: paramNumericRanges({ param }),
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: getParamNumericRange({ param }),
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
getBaseValue: () => effect.params[param.key] ?? param.default,
setBaseValue: ({ value }) => {
const coercedValue = coerceAnimationParamValue({ param, value });
const coercedValue = coerceAnimationValueForParam({ param, value });
if (coercedValue === null) {
return element;
}
@@ -143,6 +210,16 @@ function buildEffectParamDescriptor({
};
}
export function isAnimationPath(
propertyPath: string,
): propertyPath is AnimationPath {
return (
isAnimationPropertyPath(propertyPath) ||
isGraphicParamPath(propertyPath) ||
isEffectParamPath(propertyPath)
);
}
export function resolveAnimationTarget({
element,
path,
@@ -1,6 +1,6 @@
import { getElementKeyframes } from "@/animation";
import type { SceneTracks } from "@/model";
import type { TimelineSnapPoint } from "@/timeline/snapping";
import type { SceneTracks } from "@/timeline";
import type { SnapPoint } from "@/timeline/snapping";
export function getAnimationKeyframeSnapPointsForTimeline({
tracks,
@@ -8,8 +8,8 @@ export function getAnimationKeyframeSnapPointsForTimeline({
}: {
tracks: SceneTracks;
excludeElementIds?: Set<string>;
}): TimelineSnapPoint[] {
const snapPoints: TimelineSnapPoint[] = [];
}): SnapPoint[] {
const snapPoints: SnapPoint[] = [];
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
for (const track of orderedTracks) {
+177 -57
View File
@@ -1,62 +1,182 @@
import type {
AnimationInterpolation,
AnimationPath,
AnimationValue,
CurveHandle,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
TangentMode,
} from "@/model/decorations/animations";
import type { ParamValues } from "@/params";
export type {
AnimationBindingByKind,
AnimationBindingComponent,
AnimationBindingInstance,
AnimationBindingKind,
AnimationBindingOfKind,
AnimationChannel,
AnimationColorPropertyPath,
AnimationInterpolation,
AnimationKeyframe,
AnimationNumericPropertyPath,
AnimationPath,
AnimationPropertyGroup,
AnimationPropertyPath,
AnimationPropertyValueMap,
AnimationValue,
AnimationValueForPath,
ChannelExtrapolationMode,
ColorAnimationBinding,
ContinuousKeyframeInterpolation,
CurveHandle,
DiscreteAnimationBinding,
DiscreteAnimationChannel,
DiscreteAnimationKey,
DiscreteKeyframeInterpolation,
DiscreteValue,
DynamicAnimationPathValue,
EffectParamPath,
ElementAnimationBindingMap,
ElementAnimationChannelMap,
ElementAnimations,
GraphicParamPath,
NormalizedCubicBezier,
NumberAnimationBinding,
NumericSpec,
PrimitiveAnimationChannelKind,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
TangentMode,
Vector2AnimationBinding,
VectorValue,
} from "@/model/decorations/animations";
export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX",
"transform.positionY",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
"opacity",
"volume",
"color",
"background.color",
"background.paddingX",
"background.paddingY",
"background.offsetX",
"background.offsetY",
"background.cornerRadius",
] as const;
export {
ANIMATION_PROPERTY_GROUPS,
ANIMATION_PROPERTY_PATHS,
} from "@/model/decorations/animations";
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`;
export type AnimationPath =
| AnimationPropertyPath
| GraphicParamPath
| EffectParamPath;
export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"],
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue;
export interface AnimationPropertyValueMap {
"transform.positionX": number;
"transform.positionY": number;
"transform.scaleX": number;
"transform.scaleY": number;
"transform.rotate": number;
opacity: number;
volume: number;
color: string;
"background.color": string;
"background.paddingX": number;
"background.paddingY": number;
"background.offsetX": number;
"background.offsetY": number;
"background.cornerRadius": number;
}
export type DynamicAnimationPathValue = ParamValues[string];
export type AnimationValueForPath<TPath extends AnimationPath> =
TPath extends AnimationPropertyPath
? AnimationPropertyValueMap[TPath]
: TPath extends GraphicParamPath | EffectParamPath
? DynamicAnimationPathValue
: never;
export type AnimationNumericPropertyPath = {
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
}[AnimationPropertyPath];
export type AnimationColorPropertyPath = {
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
}[AnimationPropertyPath];
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold";
export type AnimationInterpolation =
| ContinuousKeyframeInterpolation
| DiscreteKeyframeInterpolation;
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
export type ScalarSegmentType = "step" | "linear" | "bezier";
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
export type ChannelExtrapolationMode = "hold" | "linear";
export interface CurveHandle {
dt: number;
dv: number;
}
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
id: string;
time: number; // relative to element start time
value: TValue;
}
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
leftHandle?: CurveHandle;
rightHandle?: CurveHandle;
segmentToNext: ScalarSegmentType;
tangentMode: TangentMode;
}
export interface DiscreteAnimationKey
extends BaseAnimationKeyframe<DiscreteValue> {}
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
export interface ScalarAnimationChannel {
kind: "scalar";
keys: ScalarAnimationKey[];
extrapolation?: {
before: ChannelExtrapolationMode;
after: ChannelExtrapolationMode;
};
}
export interface DiscreteAnimationChannel {
kind: "discrete";
keys: DiscreteAnimationKey[];
}
export type AnimationChannel =
| ScalarAnimationChannel
| DiscreteAnimationChannel;
export type ElementAnimationChannelMap = Record<
string,
AnimationChannel | undefined
>;
export interface AnimationBindingComponent<TKey extends string = string> {
key: TKey;
channelId: string;
}
interface BaseAnimationBinding<
TKind extends AnimationBindingKind,
TComponentKey extends string,
> {
path: AnimationPath;
kind: TKind;
components: AnimationBindingComponent<TComponentKey>[];
}
export interface NumberAnimationBinding
extends BaseAnimationBinding<"number", "value"> {}
export interface Vector2AnimationBinding
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
export interface ColorAnimationBinding
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
colorSpace: "srgb-linear";
}
export interface DiscreteAnimationBinding
extends BaseAnimationBinding<"discrete", "value"> {}
export type AnimationBindingInstance =
| NumberAnimationBinding
| Vector2AnimationBinding
| ColorAnimationBinding
| DiscreteAnimationBinding;
export interface AnimationBindingByKind {
number: NumberAnimationBinding;
vector2: Vector2AnimationBinding;
color: ColorAnimationBinding;
discrete: DiscreteAnimationBinding;
}
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
AnimationBindingByKind[TKind];
export type ElementAnimationBindingMap = Record<
string,
AnimationBindingInstance | undefined
>;
export interface ElementAnimations {
bindings: ElementAnimationBindingMap;
channels: ElementAnimationChannelMap;
}
export type NormalizedCubicBezier = [number, number, number, number];
export interface ScalarGraphChannelTarget {
propertyPath: AnimationPath;
-61
View File
@@ -1,61 +0,0 @@
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 -27
View File
@@ -11,17 +11,15 @@ import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/timeline/components";
import { PreviewPanel } from "@/preview/components";
import { EditorHeader } from "@/components/editor/editor-header";
import { EditorNoticeBar } from "@/components/editor/editor-notice-bar";
import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/project/components/migration-dialog";
import { usePanelStore } from "@/editor/panel-store";
import { useEditor } from "@/editor/use-editor";
import { usePasteMedia } from "@/media/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate";
import { useMemo, useState } from "react";
import { useEditor } from "@/editor/use-editor";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import { useMemo } from "react";
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
import {
createPreviewOverlayControl,
@@ -43,7 +41,7 @@ export default function Editor() {
<MobileGate>
<EditorProvider projectId={projectId}>
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
<DegradedRendererBanner />
<EditorNoticeBar />
<EditorHeader />
<div className="min-h-0 min-w-0 flex-1">
<EditorLayout />
@@ -57,27 +55,6 @@ export default function Editor() {
);
}
function DegradedRendererBanner() {
const isDegraded = useEditor((e) => e.renderer.isDegraded);
const [dismissed, setDismissed] = useState(false);
if (!isDegraded || dismissed) return null;
return (
<div className="bg-accent border-b h-9 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span>For the best experience, open OpenCut in Chrome.</span>
<Button
variant="text"
size="icon"
className="p-0 w-auto [&_svg]:size-3.5"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
>
<HugeiconsIcon icon={Cancel01Icon} />
</Button>
</div>
);
}
function EditorLayout() {
usePasteMedia();
const { panels, setPanel } = usePanelStore();
+3 -3
View File
@@ -1,6 +1,6 @@
import { getKeyframeById } from "@/animation";
import type { SelectedKeyframeRef } from "@/animation/types";
import { elementId, type ElementRef, type TimelineElement } from "@/model";
import type { TimelineElement } from "@/timeline";
import { PasteKeyframesCommand } from "@/commands/timeline";
import type {
ClipboardHandler,
@@ -12,7 +12,7 @@ function resolveSingleSourceElement({
selectedKeyframes,
}: {
selectedKeyframes: SelectedKeyframeRef[];
}): ElementRef | null {
}) {
const firstKeyframe = selectedKeyframes[0];
if (!firstKeyframe) {
return null;
@@ -20,7 +20,7 @@ function resolveSingleSourceElement({
const sourceElement = {
trackId: firstKeyframe.trackId,
elementId: elementId(firstKeyframe.elementId),
elementId: firstKeyframe.elementId,
};
const isSingleSource = selectedKeyframes.every(
(keyframe) =>
+12 -3
View File
@@ -1,8 +1,17 @@
import type { EditorCore } from "@/core";
import type { AnimationInterpolation, AnimationPath, AnimationValue } from "@/model/decorations/animations";
import type { ScalarCurveKeyframePatch, SelectedKeyframeRef } from "@/animation/types";
import type {
AnimationInterpolation,
AnimationPath,
AnimationValue,
ScalarCurveKeyframePatch,
SelectedKeyframeRef,
} from "@/animation/types";
import type { Command } from "@/commands/base-command";
import type { CreateTimelineElement, ElementRef, TrackType } from "@/model";
import type {
CreateTimelineElement,
ElementRef,
TrackType,
} from "@/timeline";
export interface ElementClipboardItem {
trackId: string;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { EditorSelectionPatch } from "@/selection/editor-selection";
import type { ElementRef } from "@/model";
import type { ElementRef } from "@/timeline/types";
export interface CommandResult {
selection?: EditorSelectionPatch;
@@ -6,7 +6,7 @@ import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service";
import { waveformCache } from "@/services/waveform-cache/service";
import { hasMediaId } from "@/timeline/element-utils";
import type { SceneTracks } from "@/model";
import type { SceneTracks } from "@/timeline";
export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/model";
import type { TScene } from "@/timeline";
import { buildDefaultScene } from "@/timeline/scenes";
export class CreateSceneCommand extends Command {
+1 -1
View File
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/model";
import type { TScene } from "@/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/timeline/scenes";
export class DeleteSceneCommand extends Command {
+1 -1
View File
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/model";
import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/timeline/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/model";
import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/timeline/scenes";
import {
getFrameTime,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/model";
import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/timeline/scenes";
export class RenameSceneCommand extends Command {
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/model";
import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/timeline/scenes";
import {
getFrameTime,
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { Bookmark, TScene } from "@/model";
import type { Bookmark, TScene } from "@/timeline";
import { updateSceneInArray } from "@/timeline/scenes";
import {
getFrameTime,
@@ -1,14 +1,14 @@
import { EditorCore } from "@/core";
import {
getKeyframeAtTime,
resolveAnimationTarget,
updateScalarKeyframeCurve,
upsertPathKeyframe,
} from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import type { KeyframeClipboardItem } from "@/clipboard";
import type { SceneTracks, TimelineElement } from "@/model";
import type { SceneTracks, TimelineElement } from "@/timeline";
import { updateElementInSceneTracks } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import { generateUUID } from "@/utils/id";
function pasteKeyframesIntoElement({
@@ -4,7 +4,7 @@ import {
type CommandResult,
} from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { ElementRef, SceneTracks, TimelineElement } from "@/model";
import type { SceneTracks, TimelineElement } from "@/timeline";
import type { ElementClipboardItem } from "@/clipboard";
import { generateUUID } from "@/utils/id";
import {
@@ -16,7 +16,7 @@ import { cloneAnimations } from "@/animation";
export class PasteCommand extends Command {
private savedState: SceneTracks | null = null;
private pastedElements: ElementRef[] = [];
private pastedElements: { trackId: string; elementId: string }[] = [];
private readonly time: number;
private readonly clipboardItems: ElementClipboardItem[];
@@ -142,7 +142,7 @@ export class PasteCommand extends Command {
}
}
getPastedElements(): ElementRef[] {
getPastedElements(): { trackId: string; elementId: string }[] {
return this.pastedElements;
}
}
@@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/commands/base-command";
import type { SceneTracks } from "@/model";
import type { SceneTracks } from "@/timeline";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/model";
import type { TimelineTrack } from "@/timeline";
function removeTrackElements<TTrack extends TimelineTrack>({
track,
@@ -3,13 +3,7 @@ import {
createElementSelectionResult,
type CommandResult,
} from "@/commands/base-command";
import {
elementId,
type ElementId,
type ElementRef,
type SceneTracks,
type TimelineElement,
} from "@/model";
import type { SceneTracks, TimelineElement } from "@/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
@@ -20,7 +14,7 @@ interface DuplicateElementsParams {
}
export class DuplicateElementsCommand extends Command {
private duplicatedElements: ElementRef[] = [];
private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: SceneTracks | null = null;
private elements: DuplicateElementsParams["elements"];
@@ -59,7 +53,7 @@ export class DuplicateElementsCommand extends Command {
continue;
}
const newId = elementId(generateUUID());
const newId = generateUUID();
newTrackElements.push(
buildDuplicateElement({
element,
@@ -113,7 +107,7 @@ export class DuplicateElementsCommand extends Command {
}
}
getDuplicatedElements(): ElementRef[] {
getDuplicatedElements(): { trackId: string; elementId: string }[] {
return this.duplicatedElements;
}
}
@@ -124,7 +118,7 @@ function buildDuplicateElement({
startTime,
}: {
element: TimelineElement;
id: ElementId;
id: string;
startTime: number;
}): TimelineElement {
return {
@@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
import type { SceneTracks, VisualElement } from "@/model";
import type { SceneTracks, VisualElement } from "@/timeline";
import { buildDefaultEffectInstance } from "@/effects";
function addEffectToElement({
@@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
import type { SceneTracks, VisualElement } from "@/model";
import type { SceneTracks, VisualElement } from "@/timeline";
function removeEffectFromElement({
element,
@@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
import type { SceneTracks, VisualElement } from "@/model";
import type { SceneTracks, VisualElement } from "@/timeline";
function reorderEffectsOnElement({
element,
@@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
import type { SceneTracks, VisualElement } from "@/model";
import type { SceneTracks, VisualElement } from "@/timeline";
export function toggleEffectOnElement({
element,
@@ -1,8 +1,8 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
import type { ParamValues } from "@/model/decorations/param-values";
import type { SceneTracks, VisualElement } from "@/model";
import type { ParamValues } from "@/params";
import type { SceneTracks, VisualElement } from "@/timeline";
function updateEffectParamsOnElement({
element,
@@ -1,13 +1,11 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import {
elementId,
type CreateTimelineElement,
type ElementId,
type SceneTracks,
type TimelineElement,
type TrackType,
} from "@/model";
import type {
CreateTimelineElement,
SceneTracks,
TimelineElement,
TrackType,
} from "@/timeline";
import { generateUUID } from "@/utils/id";
import { requiresMediaId } from "@/timeline/element-utils";
import type { MediaAsset } from "@/media/types";
@@ -31,13 +29,13 @@ export interface InsertElementParams {
}
export class InsertElementCommand extends Command {
private elementId: ElementId;
private elementId: string;
private savedState: SceneTracks | null = null;
private targetTrackId: string | null = null;
constructor({ element, placement }: InsertElementParams) {
super();
this.elementId = elementId(generateUUID());
this.elementId = generateUUID();
this.element = element;
this.placement = placement;
}
@@ -3,7 +3,7 @@ import { Command, type CommandResult } from "@/commands/base-command";
import { removeEffectParamKeyframe } from "@/animation/effect-param-channel";
import { updateElementInSceneTracks } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils";
import type { SceneTracks } from "@/model";
import type { SceneTracks } from "@/timeline";
export class RemoveEffectParamKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -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 "@/model/decorations/animations";
import type { SceneTracks, TimelineElement } from "@/model";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { AnimationPath, AnimationValue } from "@/animation/types";
import type { SceneTracks, TimelineElement } from "@/timeline";
function removeKeyframeAndPersist({
element,
@@ -1,10 +1,9 @@
import { EditorCore } from "@/core";
import { retimeElementKeyframe } from "@/animation";
import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath } from "@/model/decorations/animations";
import type { SceneTracks } from "@/model";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { AnimationPath } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
export class RetimeKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -1,13 +1,15 @@
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 } from "@/model/decorations/animations";
import type { ScalarCurveKeyframePatch } from "@/animation/types";
import type { SceneTracks } from "@/model";
import type {
AnimationPath,
ScalarCurveKeyframePatch,
} from "@/animation/types";
import type { SceneTracks } from "@/timeline";
export class UpdateScalarKeyframeCurveCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -2,13 +2,13 @@ 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 "@/model/decorations/animations";
import type { SceneTracks } from "@/model";
import type { AnimationInterpolation } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -1,10 +1,13 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import { upsertPathKeyframe } from "@/animation";
import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation";
import { updateElementInSceneTracks } from "@/timeline";
import type { SceneTracks } from "@/model";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { AnimationInterpolation, AnimationPath, AnimationValue } from "@/model/decorations/animations";
import type { SceneTracks } from "@/timeline";
import type {
AnimationPath,
AnimationInterpolation,
AnimationValue,
} from "@/animation/types";
export class UpsertKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -4,9 +4,9 @@ import {
getCustomMaskClosedStateAfterPointRemoval,
removeCustomMaskPoints,
} from "@/masks/custom-path";
import type { CustomMask } from "@/model/decorations/mask";
import type { CustomMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/model";
import type { MaskableElement, SceneTracks } from "@/timeline";
function deletePointsFromCustomMask({
mask,
@@ -2,9 +2,9 @@ import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom";
import type { ElementBounds } from "@/preview/element-bounds";
import type { CustomMask } from "@/model/decorations/mask";
import type { CustomMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/model";
import type { MaskableElement, SceneTracks } from "@/timeline";
function insertPointIntoCustomMask({
mask,
@@ -1,7 +1,7 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/model";
import type { SceneTracks, MaskableElement } from "@/timeline";
function removeMaskFromElement({
element,
@@ -1,8 +1,8 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { Mask } from "@/model/decorations/mask";
import type { MaskableElement, SceneTracks } from "@/model";
import type { Mask } from "@/masks/types";
import type { SceneTracks, MaskableElement } from "@/timeline";
export function toggleMaskInvertedOnElement({
element,
@@ -4,12 +4,7 @@ import {
type CommandResult,
} from "@/commands/base-command";
import { EditorCore } from "@/core";
import {
elementId as toElementId,
type SceneTracks,
type TimelineElement,
type TimelineTrack,
} from "@/model";
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
import {
buildEmptyTrack,
validateElementTrackCompatibility,
@@ -122,7 +117,7 @@ export class MoveElementCommand extends Command {
return createElementSelectionResult(
this.moves.map(({ elementId, targetTrackId }) => ({
trackId: targetTrackId,
elementId: toElementId(elementId),
elementId,
})),
);
}
@@ -3,12 +3,7 @@ import {
createElementSelectionResult,
type CommandResult,
} from "@/commands/base-command";
import {
elementId,
type ElementRef,
type SceneTracks,
type TimelineElement,
} from "@/model";
import type { SceneTracks, TimelineElement } from "@/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { isRetimableElement } from "@/timeline";
@@ -17,7 +12,7 @@ import { getSourceSpanAtClipTime } from "@/retime";
export class SplitElementsCommand extends Command {
private savedState: SceneTracks | null = null;
private rightSideElements: ElementRef[] = [];
private rightSideElements: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number;
private readonly retainSide: "both" | "left" | "right";
@@ -37,7 +32,7 @@ export class SplitElementsCommand extends Command {
this.retainSide = retainSide;
}
getRightSideElements(): ElementRef[] {
getRightSideElements(): { trackId: string; elementId: string }[] {
return this.rightSideElements;
}
@@ -112,7 +107,7 @@ export class SplitElementsCommand extends Command {
},
];
} else if (this.retainSide === "right") {
const newId = elementId(generateUUID());
const newId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: newId,
@@ -131,7 +126,7 @@ export class SplitElementsCommand extends Command {
];
} else {
// "both" - split into two pieces
const secondElementId = elementId(generateUUID());
const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
@@ -10,12 +10,11 @@ import {
resolveTrackPlacement,
} from "@/timeline/placement";
import { updateElementInSceneTracks } from "@/timeline/track-element-update";
import {
elementId,
type SceneTracks,
type TimelineElement,
type VideoElement,
} from "@/model";
import type {
SceneTracks,
TimelineElement,
VideoElement,
} from "@/timeline/types";
import { generateUUID } from "@/utils/id";
export class ToggleSourceAudioSeparationCommand extends Command {
@@ -76,7 +75,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
...buildSeparatedAudioElement({
sourceElement: videoElement,
}),
id: elementId(generateUUID()),
id: generateUUID(),
};
const placementResult = resolveTrackPlacement({
tracks: this.savedState,
@@ -1,6 +1,6 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import type { SceneTracks, TimelineElement } from "@/model";
import type { SceneTracks, TimelineElement } from "@/timeline";
import {
findTrackInSceneTracks,
updateElementInSceneTracks,
@@ -1,5 +1,5 @@
import { Command, type CommandResult } from "@/commands/base-command";
import type { SceneTracks, TrackType } from "@/model";
import type { SceneTracks, TrackType } from "@/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
@@ -1,6 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type { SceneTracks } from "@/model";
import type { SceneTracks } from "@/timeline";
export class RemoveTrackCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -1,5 +1,5 @@
import { Command, type CommandResult } from "@/commands/base-command";
import type { SceneTracks } from "@/model";
import type { SceneTracks } from "@/timeline";
import { EditorCore } from "@/core";
import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline";
@@ -1,5 +1,5 @@
import { Command, type CommandResult } from "@/commands/base-command";
import type { SceneTracks } from "@/model";
import type { SceneTracks } from "@/timeline";
import { EditorCore } from "@/core";
import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline";
@@ -1,5 +1,5 @@
import { Command, type CommandResult } from "@/commands/base-command";
import type { SceneTracks } from "@/model";
import type { SceneTracks } from "@/timeline";
import { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command {
@@ -0,0 +1,49 @@
"use client";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEditor } from "@/editor/use-editor";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/ui";
const toneClassName = {
info: "bg-accent text-muted-foreground",
warning: "bg-caution/10 text-caution",
error: "bg-destructive/10 text-destructive",
} as const;
export function EditorNoticeBar() {
const editor = useEditor();
const notices = useEditor((e) => e.notices.getNotices());
if (notices.length === 0) {
return null;
}
return (
<div className="flex flex-col">
{notices.map((notice) => (
<div
key={notice.id}
className={cn(
"border-b h-9 flex items-center justify-center gap-2 px-3 text-xs",
toneClassName[notice.tone],
)}
>
<span>{notice.message}</span>
{notice.dismissible ? (
<Button
variant="text"
size="icon"
className="p-0 w-auto [&_svg]:size-3.5"
onClick={() => editor.notices.dismiss({ id: notice.id })}
aria-label="Dismiss"
>
<HugeiconsIcon icon={Cancel01Icon} />
</Button>
) : null}
</div>
))}
</div>
);
}
@@ -45,7 +45,7 @@ import {
type MediaViewMode,
useAssetsPanelStore,
} from "@/components/editor/panels/assets/assets-panel-store";
import { MASKABLE_ELEMENT_TYPES } from "@/model";
import { MASKABLE_ELEMENT_TYPES } from "@/timeline";
import type { MediaAsset } from "@/media/types";
import { cn } from "@/utils/ui";
import {
@@ -1,6 +1,6 @@
"use client";
import type { NumberParamDefinition, ParamDefinition } from "@/model/decorations/param-values";
import type { ParamDefinition, NumberParamDefinition } from "@/params";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
@@ -1,95 +1,95 @@
import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/model/decorations/animations";
import type { TimelineElement } from "@/model";
import { upsertElementKeyframe } from "@/timeline/animation-properties";
export function useKeyframedColorProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
resolvedColor,
buildBaseUpdates,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedColor: string;
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
}) {
const editor = useEditor();
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const onChange = ({ color }: { color: string }) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertElementKeyframe({
animations,
propertyPath,
time: localTime,
value: color,
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
});
};
const onChangeEnd = () => editor.timeline.commitPreview();
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
],
});
};
return {
isKeyframedAtTime,
hasAnimatedKeyframes,
keyframeIdAtTime,
onChange,
onChangeEnd,
toggleKeyframe,
};
}
import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
export function useKeyframedColorProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
resolvedColor,
buildBaseUpdates,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedColor: string;
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
}) {
const editor = useEditor();
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const onChange = ({ color }: { color: string }) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertElementKeyframe({
animations,
propertyPath,
time: localTime,
value: color,
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
});
};
const onChangeEnd = () => editor.timeline.commitPreview();
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
],
});
};
return {
isKeyframedAtTime,
hasAnimatedKeyframes,
keyframeIdAtTime,
onChange,
onChangeEnd,
toggleKeyframe,
};
}
@@ -1,174 +1,174 @@
import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/model/decorations/animations";
import type { TimelineElement } from "@/model";
import { upsertElementKeyframe } from "@/timeline/animation-properties";
import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
displayValue,
parse,
valueAtPlayhead,
step,
buildBaseUpdates,
buildAdditionalKeyframes,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
isPlayheadWithinElementRange: boolean;
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
step?: number;
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
buildAdditionalKeyframes?: ({
value,
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
}) {
const editor = useEditor();
const snapValue = (value: number) =>
step != null ? snapToStep({ value, step }) : value;
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
const updatedAnimations = [
{ propertyPath, value: nextValue },
...additionalKeyframes,
].reduce(
(currentAnimations, keyframe) =>
upsertElementKeyframe({
animations: currentAnimations,
propertyPath: keyframe.propertyPath,
time: localTime,
value: keyframe.value,
}),
animations,
);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: { animations: updatedAnimations },
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value: nextValue }),
},
],
});
};
const propertyDraft = usePropertyDraft({
displayValue,
parse: (input) => {
const parsedValue = parse(input);
return parsedValue === null ? null : snapValue(parsedValue);
},
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
keyframeId: keyframeIdAtTime,
},
],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value: snapValue(valueAtPlayhead),
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
...additionalKeyframes.map((keyframe) => ({
trackId,
elementId,
propertyPath: keyframe.propertyPath,
time: localTime,
value: keyframe.value,
})),
],
});
return;
}
editor.timeline.updateElements({
updates: [
{
trackId,
elementId,
patch: buildBaseUpdates({ value: nextValue }),
},
],
});
};
return {
...propertyDraft,
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
toggleKeyframe,
commitValue,
};
}
import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
displayValue,
parse,
valueAtPlayhead,
step,
buildBaseUpdates,
buildAdditionalKeyframes,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
isPlayheadWithinElementRange: boolean;
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
step?: number;
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
buildAdditionalKeyframes?: ({
value,
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
}) {
const editor = useEditor();
const snapValue = (value: number) =>
step != null ? snapToStep({ value, step }) : value;
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
const updatedAnimations = [
{ propertyPath, value: nextValue },
...additionalKeyframes,
].reduce(
(currentAnimations, keyframe) =>
upsertElementKeyframe({
animations: currentAnimations,
propertyPath: keyframe.propertyPath,
time: localTime,
value: keyframe.value,
}),
animations,
);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: { animations: updatedAnimations },
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value: nextValue }),
},
],
});
};
const propertyDraft = usePropertyDraft({
displayValue,
parse: (input) => {
const parsedValue = parse(input);
return parsedValue === null ? null : snapValue(parsedValue);
},
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
keyframeId: keyframeIdAtTime,
},
],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value: snapValue(valueAtPlayhead),
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
...additionalKeyframes.map((keyframe) => ({
trackId,
elementId,
propertyPath: keyframe.propertyPath,
time: localTime,
value: keyframe.value,
})),
],
});
return;
}
editor.timeline.updateElements({
updates: [
{
trackId,
elementId,
patch: buildBaseUpdates({ value: nextValue }),
},
],
});
};
return {
...propertyDraft,
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
toggleKeyframe,
commitValue,
};
}
@@ -3,18 +3,18 @@
import { useEditor } from "@/editor/use-editor";
import {
buildGraphicParamPath,
coerceAnimationValueForParam,
getKeyframeAtTime,
getParamDefaultInterpolation,
getParamValueKind,
hasKeyframesForPath,
upsertPathKeyframe,
} from "@/animation";
import type { ElementAnimations } from "@/model/decorations/animations";
import {
coerceAnimationParamValue,
getAnimationParamDefaultInterpolation,
getAnimationParamValueKind,
} from "@/animation/animated-params";
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { TimelineElement } from "@/model";
import type {
ElementAnimations,
} from "@/animation/types";
import type { ParamDefinition } from "@/params";
import type { TimelineElement } from "@/timeline";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
@@ -79,12 +79,12 @@ export function useKeyframedParamProperty({
propertyPath,
time: localTime,
value,
kind: getAnimationParamValueKind({ param }),
defaultInterpolation: getAnimationParamDefaultInterpolation({
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({
param,
}),
coerceValue: ({ value: nextValue }) =>
coerceAnimationParamValue({
coerceAnimationValueForParam({
param,
value: nextValue,
}),
@@ -1,5 +1,17 @@
import type { ReactNode } from "react";
import type { AudioElement, EffectElement, GraphicElement, ImageElement, MaskableElement, RetimableElement, StickerElement, TextElement, TimelineElement, VideoElement, VisualElement } from "@/model";
import type {
EffectElement,
GraphicElement,
ImageElement,
MaskableElement,
RetimableElement,
StickerElement,
TextElement,
VisualElement,
VideoElement,
AudioElement,
TimelineElement,
} from "@/timeline";
import type { MediaAsset } from "@/media/types";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -12,8 +12,37 @@ import { useEditorActions } from "@/actions/use-editor-actions";
import { loadFontAtlas } from "@/fonts/google-fonts";
import {
initializeGpuRenderer,
isGpuAvailable,
type GpuRendererInitializationResult,
} from "@/services/renderer/gpu-renderer";
import type { EditorNoticeInput } from "@/core/managers/editor-notices-manager";
const RENDERER_NOTICES: Record<
GpuRendererInitializationResult["kind"],
EditorNoticeInput | null
> = {
ready: null,
"software-fallback-adapter": {
id: "software-fallback-adapter",
tone: "warning",
message:
"This browser is using a software-only graphics adapter. Preview rendering may fail or appear blank. If that happens, switch to a hardware-accelerated browser.",
dismissible: true,
},
"webgl-fallback": {
id: "webgl-fallback",
tone: "info",
message:
"WebGPU is unavailable in this browser, so OpenCut is rendering with a WebGL fallback. Performance and visual fidelity may be reduced.",
dismissible: true,
},
unavailable: {
id: "gpu-unavailable",
tone: "warning",
message:
"GPU rendering is unavailable in this browser. OpenCut may run with degraded rendering support. For the best experience, use a hardware-accelerated browser.",
dismissible: true,
},
};
interface EditorProviderProps {
projectId: string;
@@ -38,8 +67,12 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
const loadProject = async () => {
try {
setIsLoading(true);
await initializeGpuRenderer();
editor.renderer.setDegraded(!isGpuAvailable());
const gpuInitializationResult = await initializeGpuRenderer();
const rendererNotice = RENDERER_NOTICES[gpuInitializationResult.kind];
editor.notices.setScopeNotices({
scope: "renderer",
notices: rendererNotice ? [rendererNotice] : [],
});
await editor.project.loadProject({ id: projectId });
if (cancelled) return;
+3
View File
@@ -10,6 +10,7 @@ import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { ClipboardManager } from "./managers/clipboard-manager";
import { DiagnosticsManager } from "./managers/diagnostics-manager";
import { EditorNoticesManager } from "./managers/editor-notices-manager";
import { registerDefaultEffects } from "@/effects";
import { registerDefaultMasks } from "@/masks";
import { registerTranscriptionDiagnostics } from "@/transcription/diagnostics";
@@ -28,6 +29,7 @@ export class EditorCore {
public readonly selection: SelectionManager;
public readonly clipboard: ClipboardManager;
public readonly diagnostics: DiagnosticsManager;
public readonly notices: EditorNoticesManager;
private constructor() {
registerDefaultEffects();
@@ -44,6 +46,7 @@ export class EditorCore {
this.selection = new SelectionManager(this);
this.clipboard = new ClipboardManager(this);
this.diagnostics = new DiagnosticsManager(this);
this.notices = new EditorNoticesManager();
registerTranscriptionDiagnostics({ diagnostics: this.diagnostics });
this.playback.bindTimelineScope();
this.command.registerReactor(() => {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/commands";
import type { EditorSelectionSnapshot } from "@/selection/editor-selection";
import { applyRippleAdjustments, computeRippleAdjustments } from "@/ripple";
import type { SceneTracks } from "@/model";
import type { SceneTracks } from "@/timeline/types";
interface CommandHistoryEntry {
command: Command;
@@ -0,0 +1,126 @@
export type EditorNoticeTone = "info" | "warning" | "error";
export type EditorNoticeInput = {
id: string;
tone: EditorNoticeTone;
message: string;
dismissible?: boolean;
};
export type EditorNotice = EditorNoticeInput & {
scope: string;
};
export class EditorNoticesManager {
private noticesByScope = new Map<string, EditorNotice[]>();
private dismissedIds = new Set<string>();
private listeners = new Set<() => void>();
private cachedNotices: EditorNotice[] = [];
private cacheValid = false;
setScopeNotices({
scope,
notices,
}: {
scope: string;
notices: EditorNoticeInput[];
}): void {
const nextNotices = notices.map((notice) => ({
...notice,
id: `${scope}:${notice.id}`,
scope,
}));
const previousNotices = this.noticesByScope.get(scope) ?? [];
if (areNoticeListsEqual(previousNotices, nextNotices)) {
return;
}
if (nextNotices.length === 0) {
this.noticesByScope.delete(scope);
} else {
this.noticesByScope.set(scope, nextNotices);
}
this.pruneStaleDismissals();
this.invalidateCache();
this.notify();
}
clearScope(scope: string): void {
if (!this.noticesByScope.has(scope)) {
return;
}
this.noticesByScope.delete(scope);
this.pruneStaleDismissals();
this.invalidateCache();
this.notify();
}
dismiss({ id }: { id: string }): void {
if (this.dismissedIds.has(id)) {
return;
}
this.dismissedIds.add(id);
this.invalidateCache();
this.notify();
}
getNotices(): EditorNotice[] {
if (!this.cacheValid) {
this.cachedNotices = [...this.noticesByScope.values()]
.flat()
.filter((notice) => !this.dismissedIds.has(notice.id));
this.cacheValid = true;
}
return this.cachedNotices;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private invalidateCache(): void {
this.cacheValid = false;
}
private pruneStaleDismissals(): void {
if (this.dismissedIds.size === 0) {
return;
}
const liveIds = new Set<string>();
for (const notices of this.noticesByScope.values()) {
for (const notice of notices) {
liveIds.add(notice.id);
}
}
for (const id of this.dismissedIds) {
if (!liveIds.has(id)) {
this.dismissedIds.delete(id);
}
}
}
private notify(): void {
this.listeners.forEach((listener) => {
listener();
});
}
}
function areNoticeListsEqual(
left: EditorNotice[],
right: EditorNotice[],
): boolean {
if (left.length !== right.length) {
return false;
}
return left.every((notice, index) => {
const other = right[index];
return (
notice.id === other?.id &&
notice.scope === other.scope &&
notice.tone === other.tone &&
notice.message === other.message &&
notice.dismissible === other.dismissible
);
});
}
+3 -13
View File
@@ -6,7 +6,6 @@ import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/media/audio";
import { formatTimecode } from "opencut-wasm";
import { frameRateToFloat } from "@/fps/utils";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
@@ -15,21 +14,10 @@ type SnapshotResult =
export class RendererManager {
private renderTree: RootNode | null = null;
private _isDegraded = false;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
get isDegraded(): boolean {
return this._isDegraded;
}
setDegraded(degraded: boolean): void {
if (this._isDegraded === degraded) return;
this._isDegraded = degraded;
this.notify();
}
setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
this.renderTree = renderTree;
this.notify();
@@ -122,7 +110,9 @@ export class RendererManager {
return { success: false, error: "Failed to create image" };
}
const timecode = formatTimecode({ time: renderTime, rate: fps })!.replace(/:/g, "-");
const timecode =
formatTimecode({ time: renderTime, rate: fps })?.replace(/:/g, "-") ??
"unknown-time";
const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { EditorCore } from "@/core";
import type { SceneTracks, TScene } from "@/model";
import type { SceneTracks, TScene } from "@/timeline";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
@@ -6,7 +6,7 @@ import type {
EditorSelectionSnapshot,
SelectedMaskPointSelection,
} from "@/selection/editor-selection";
import type { ElementRef } from "@/model";
import type { ElementRef } from "@/timeline/types";
export class SelectionManager {
private selectedElements: ElementRef[] = [];
+16 -5
View File
@@ -1,7 +1,13 @@
import type { EditorCore } from "@/core";
import type { ElementBounds } from "@/preview/element-bounds";
import type { ParamValues } from "@/model/decorations/param-values";
import type { RetimeConfig, SceneTracks, TimelineElement, TimelineTrack, TrackType } from "@/model";
import type { ParamValues } from "@/params";
import type {
SceneTracks,
TrackType,
TimelineTrack,
TimelineElement,
RetimeConfig,
} from "@/timeline";
import { calculateTotalDuration } from "@/timeline";
import { TimelineDragSource } from "@/timeline/drag-source";
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
@@ -9,13 +15,18 @@ import {
canElementBeHidden,
canElementHaveAudio,
} from "@/timeline/element-utils";
import type { AnimationInterpolation, AnimationPath, AnimationValue, AnimationValueForPath } from "@/model/decorations/animations";
import type { ScalarCurveKeyframePatch } from "@/animation/types";
import type {
AnimationPath,
AnimationInterpolation,
AnimationValue,
AnimationValueForPath,
ScalarCurveKeyframePatch,
} from "@/animation/types";
import {
getElementLocalTime,
resolveAnimationTarget,
resolveAnimationPathValueAtTime,
} from "@/animation";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import { lastFrameTime } from "opencut-wasm";
import { BatchCommand } from "@/commands";
import {
+1
View File
@@ -33,6 +33,7 @@ export function useEditor<T>(
editor.selection.subscribe(onChange),
editor.clipboard.subscribe(onChange),
editor.diagnostics.subscribe(onChange),
editor.notices.subscribe(onChange),
];
return () => {
unsubscribers.forEach((unsubscribe) => {
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import type { ParamValues } from "@/model/decorations/param-values";
import type { Effect } from "@/model/decorations/effect";
import type { EffectElement, VisualElement } from "@/model";
import type { ParamValues } from "@/params";
import type { Effect } from "@/effects/types";
import type { EffectElement, VisualElement } from "@/timeline";
import { effectsRegistry } from "@/effects";
import { useEditor } from "@/editor/use-editor";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+1 -2
View File
@@ -1,5 +1,4 @@
import type { EffectPass } from "@/model/decorations/effect";
import type { EffectDefinition } from "@/effects/types";
import type { EffectDefinition, EffectPass } from "@/effects/types";
export const GAUSSIAN_BLUR_SHADER = "gaussian-blur";
+3 -4
View File
@@ -1,10 +1,9 @@
import { generateUUID } from "@/utils/id";
import { buildDefaultParamValues } from "@/params/registry";
import { effectsRegistry } from "./registry";
import type { ParamValues } from "@/model/decorations/param-values";
import type { Effect, EffectPass } from "@/model/decorations/effect";
import type { EffectDefinition } from "@/effects/types";
import { VISUAL_ELEMENT_TYPES } from "@/model";
import type { ParamValues } from "@/params";
import type { Effect, EffectDefinition, EffectPass } from "@/effects/types";
import { VISUAL_ELEMENT_TYPES } from "@/timeline";
export { effectsRegistry } from "./registry";
export { registerDefaultEffects } from "./definitions";
+23 -8
View File
@@ -1,12 +1,27 @@
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
import type { EffectPass, EffectPassTemplate } from "@/model/decorations/effect";
import type { ParamDefinition, ParamValues } from "@/params";
export type {
Effect,
EffectPass,
EffectPassTemplate,
EffectUniformValue,
} from "@/model/decorations/effect";
export interface Effect {
id: string;
type: string;
params: ParamValues;
enabled: boolean;
}
export type EffectUniformValue = number | number[];
export interface EffectPass {
shader: string;
uniforms: Record<string, EffectUniformValue>;
}
export interface EffectPassTemplate {
shader: string;
uniforms(params: {
effectParams: ParamValues;
width: number;
height: number;
}): Record<string, EffectUniformValue>;
}
export interface EffectRendererConfig {
passes: EffectPassTemplate[];
+238 -241
View File
@@ -1,241 +1,238 @@
"use client";
import { useRef } from "react";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import {
useKeyframedParamProperty,
type KeyframedParamPropertyResult,
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
import type { GraphicElement } from "@/model";
import {
graphicsRegistry,
registerDefaultGraphics,
resolveGraphicElementParamsAtTime,
} from "@/graphics";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
import { useEditor } from "@/editor/use-editor";
import {
Section,
SectionContent,
SectionFields,
SectionHeader,
SectionTitle,
} from "@/components/section";
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
registerDefaultGraphics();
const DEFAULT_STROKE_WIDTH = 2;
export function GraphicTab({
element,
trackId,
}: {
element: GraphicElement;
trackId: string;
}) {
const definition = graphicsRegistry.get(element.definitionId);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const { renderElement } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const liveElement = renderElement as GraphicElement;
const resolvedParams = resolveGraphicElementParamsAtTime({
element: liveElement,
localTime,
});
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
return (
<div className="flex flex-col">
<Section collapsible sectionKey={`${element.id}:graphic`}>
<SectionHeader>
<SectionTitle>{definition.name}</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
{shapeParams.map((param) => (
<AnimatedGraphicParamField
key={param.key}
param={param}
trackId={trackId}
element={liveElement}
localTime={localTime}
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
resolvedParams={resolvedParams}
/>
))}
</SectionFields>
</SectionContent>
</Section>
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
</div>
);
}
function StrokeSection({
element,
trackId,
}: {
element: GraphicElement;
trackId: string;
}) {
const editor = useEditor();
const definition = graphicsRegistry.get(element.definitionId);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const { renderElement } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const liveElement = renderElement as GraphicElement;
const resolvedParams = resolveGraphicElementParamsAtTime({
element: liveElement,
localTime,
});
const strokeParams = definition.params.filter((p) => p.group === "stroke");
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
const toggleStroke = () => {
if (isStrokeEnabled) {
lastStrokeWidth.current = Number(
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
);
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
patch: { params: { ...element.params, strokeWidth: 0 } },
},
],
});
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
patch: {
params: {
...element.params,
strokeWidth: lastStrokeWidth.current,
},
},
},
],
});
}
};
return (
<Section
collapsible
defaultOpen={isStrokeEnabled}
sectionKey={`${element.id}:stroke`}
>
<SectionHeader
trailing={
<Button
variant="ghost"
size="icon"
onClick={(event) => {
event.stopPropagation();
toggleStroke();
}}
>
<HugeiconsIcon
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
strokeWidth={1}
/>
</Button>
}
>
<SectionTitle>Stroke</SectionTitle>
</SectionHeader>
<SectionContent
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
>
<SectionFields>
{strokeParams.map((param) => (
<AnimatedGraphicParamField
key={param.key}
param={param}
trackId={trackId}
element={liveElement}
localTime={localTime}
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
resolvedParams={resolvedParams}
/>
))}
</SectionFields>
</SectionContent>
</Section>
);
}
function AnimatedGraphicParamField({
param,
trackId,
element,
localTime,
isPlayheadWithinElementRange,
resolvedParams,
}: {
param: ParamDefinition;
trackId: string;
element: GraphicElement;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedParams: ParamValues;
}) {
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
{
param,
trackId,
elementId: element.id,
animations: element.animations,
localTime,
isPlayheadWithinElementRange,
resolvedValue: resolvedParams[param.key] ?? param.default,
buildBaseUpdates: ({ value }) => ({
params: {
...element.params,
[param.key]: value,
},
}),
},
);
return (
<PropertyParamField
param={param}
value={resolvedParams[param.key] ?? param.default}
onPreview={animatedParam.onPreview}
onCommit={animatedParam.onCommit}
keyframe={{
isActive: animatedParam.isKeyframedAtTime,
isDisabled: !isPlayheadWithinElementRange,
onToggle: animatedParam.toggleKeyframe,
}}
/>
);
}
"use client";
import { useRef } from "react";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import {
useKeyframedParamProperty,
type KeyframedParamPropertyResult,
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
import { resolveGraphicParamsAtTime } from "@/animation";
import type { ParamDefinition, ParamValues } from "@/params";
import type { GraphicElement } from "@/timeline";
import { graphicsRegistry, registerDefaultGraphics } from "@/graphics";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
import { useEditor } from "@/editor/use-editor";
import {
Section,
SectionContent,
SectionFields,
SectionHeader,
SectionTitle,
} from "@/components/section";
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
registerDefaultGraphics();
const DEFAULT_STROKE_WIDTH = 2;
export function GraphicTab({
element,
trackId,
}: {
element: GraphicElement;
trackId: string;
}) {
const definition = graphicsRegistry.get(element.definitionId);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const { renderElement } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const liveElement = renderElement as GraphicElement;
const resolvedParams = resolveGraphicParamsAtTime({
element: liveElement,
localTime,
});
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
return (
<div className="flex flex-col">
<Section collapsible sectionKey={`${element.id}:graphic`}>
<SectionHeader>
<SectionTitle>{definition.name}</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
{shapeParams.map((param) => (
<AnimatedGraphicParamField
key={param.key}
param={param}
trackId={trackId}
element={liveElement}
localTime={localTime}
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
resolvedParams={resolvedParams}
/>
))}
</SectionFields>
</SectionContent>
</Section>
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
</div>
);
}
function StrokeSection({
element,
trackId,
}: {
element: GraphicElement;
trackId: string;
}) {
const editor = useEditor();
const definition = graphicsRegistry.get(element.definitionId);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const { renderElement } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const liveElement = renderElement as GraphicElement;
const resolvedParams = resolveGraphicParamsAtTime({
element: liveElement,
localTime,
});
const strokeParams = definition.params.filter((p) => p.group === "stroke");
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
const toggleStroke = () => {
if (isStrokeEnabled) {
lastStrokeWidth.current = Number(
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
);
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
patch: { params: { ...element.params, strokeWidth: 0 } },
},
],
});
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
patch: {
params: {
...element.params,
strokeWidth: lastStrokeWidth.current,
},
},
},
],
});
}
};
return (
<Section
collapsible
defaultOpen={isStrokeEnabled}
sectionKey={`${element.id}:stroke`}
>
<SectionHeader
trailing={
<Button
variant="ghost"
size="icon"
onClick={(event) => {
event.stopPropagation();
toggleStroke();
}}
>
<HugeiconsIcon
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
strokeWidth={1}
/>
</Button>
}
>
<SectionTitle>Stroke</SectionTitle>
</SectionHeader>
<SectionContent
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
>
<SectionFields>
{strokeParams.map((param) => (
<AnimatedGraphicParamField
key={param.key}
param={param}
trackId={trackId}
element={liveElement}
localTime={localTime}
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
resolvedParams={resolvedParams}
/>
))}
</SectionFields>
</SectionContent>
</Section>
);
}
function AnimatedGraphicParamField({
param,
trackId,
element,
localTime,
isPlayheadWithinElementRange,
resolvedParams,
}: {
param: ParamDefinition;
trackId: string;
element: GraphicElement;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedParams: ParamValues;
}) {
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
{
param,
trackId,
elementId: element.id,
animations: element.animations,
localTime,
isPlayheadWithinElementRange,
resolvedValue: resolvedParams[param.key] ?? param.default,
buildBaseUpdates: ({ value }) => ({
params: {
...element.params,
[param.key]: value,
},
}),
},
);
return (
<PropertyParamField
param={param}
value={resolvedParams[param.key] ?? param.default}
onPreview={animatedParam.onPreview}
onCommit={animatedParam.onCommit}
keyframe={{
isActive: animatedParam.isKeyframedAtTime,
isDisabled: !isPlayheadWithinElementRange,
onToggle: animatedParam.toggleKeyframe,
}}
/>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { ParamDefinition } from "@/params";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { ParamDefinition } from "@/params";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
@@ -1,4 +1,4 @@
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { ParamDefinition } from "@/params";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { ParamDefinition } from "@/params";
export type GraphicStrokeAlign = "inside" | "center" | "outside";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { ParamDefinition } from "@/params";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
+125 -149
View File
@@ -1,149 +1,125 @@
import { resolveGraphicParamsAtTime } from "@/animation";
import type { ElementAnimations } from "@/model/decorations/animations";
import { buildDefaultParamValues } from "@/params/registry";
import type { ParamValues } from "@/model/decorations/param-values";
import { graphicsRegistry } from "./registry";
import {
registerDefaultGraphics,
ellipseGraphicDefinition,
polygonGraphicDefinition,
rectangleGraphicDefinition,
starGraphicDefinition,
} from "./definitions";
import {
DEFAULT_GRAPHIC_SOURCE_SIZE,
type GraphicInstance,
type GraphicDefinition,
} from "./types";
const graphicPreviewUrlCache = new Map<string, string>();
const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
const FALLBACK_FILL_OPACITY = 0.08;
const FALLBACK_MIN_FONT_SIZE = 12;
const FALLBACK_FONT_SIZE_RATIO = 0.15;
function buildFallbackPreviewUrl({
name,
size,
}: {
name: string;
size: number;
}): string {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
</svg>
`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export function getGraphicDefinition({
definitionId,
}: {
definitionId: string;
}): GraphicDefinition {
registerDefaultGraphics();
return graphicsRegistry.get(definitionId);
}
export function buildDefaultGraphicInstance({
definitionId,
}: {
definitionId: string;
}): GraphicInstance {
const definition = getGraphicDefinition({ definitionId });
return {
definitionId,
params: buildDefaultParamValues(definition.params),
};
}
export function resolveGraphicParams(
definition: GraphicDefinition,
params?: ParamValues,
): ParamValues {
return {
...buildDefaultParamValues(definition.params),
...(params ?? {}),
};
}
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,
size = DEFAULT_GRAPHIC_SOURCE_SIZE,
}: {
definitionId: string;
params?: ParamValues;
size?: number;
}): string {
const definition = getGraphicDefinition({ definitionId });
const resolvedParams = resolveGraphicParams(definition, params);
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
if (cachedUrl) {
return cachedUrl;
}
if (typeof document === "undefined") {
return buildFallbackPreviewUrl({ name: definition.name, size });
}
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) {
return buildFallbackPreviewUrl({ name: definition.name, size });
}
definition.render({
ctx,
params: resolvedParams,
width: size,
height: size,
});
const previewUrl = canvas.toDataURL("image/png");
graphicPreviewUrlCache.set(cacheKey, previewUrl);
return previewUrl;
}
export {
DEFAULT_GRAPHIC_SOURCE_SIZE,
ellipseGraphicDefinition,
graphicsRegistry,
polygonGraphicDefinition,
rectangleGraphicDefinition,
registerDefaultGraphics,
starGraphicDefinition,
};
export type {
GraphicDefinition,
GraphicInstance,
GraphicRenderContext,
} from "./types";
import { buildDefaultParamValues } from "@/params/registry";
import type { ParamValues } from "@/params";
import { graphicsRegistry } from "./registry";
import {
registerDefaultGraphics,
ellipseGraphicDefinition,
polygonGraphicDefinition,
rectangleGraphicDefinition,
starGraphicDefinition,
} from "./definitions";
import {
DEFAULT_GRAPHIC_SOURCE_SIZE,
type GraphicInstance,
type GraphicDefinition,
} from "./types";
const graphicPreviewUrlCache = new Map<string, string>();
const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
const FALLBACK_FILL_OPACITY = 0.08;
const FALLBACK_MIN_FONT_SIZE = 12;
const FALLBACK_FONT_SIZE_RATIO = 0.15;
function buildFallbackPreviewUrl({
name,
size,
}: {
name: string;
size: number;
}): string {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
</svg>
`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export function getGraphicDefinition({
definitionId,
}: {
definitionId: string;
}): GraphicDefinition {
registerDefaultGraphics();
return graphicsRegistry.get(definitionId);
}
export function buildDefaultGraphicInstance({
definitionId,
}: {
definitionId: string;
}): GraphicInstance {
const definition = getGraphicDefinition({ definitionId });
return {
definitionId,
params: buildDefaultParamValues(definition.params),
};
}
export function resolveGraphicParams(
definition: GraphicDefinition,
params?: ParamValues,
): ParamValues {
return {
...buildDefaultParamValues(definition.params),
...(params ?? {}),
};
}
export function buildGraphicPreviewUrl({
definitionId,
params,
size = DEFAULT_GRAPHIC_SOURCE_SIZE,
}: {
definitionId: string;
params?: ParamValues;
size?: number;
}): string {
const definition = getGraphicDefinition({ definitionId });
const resolvedParams = resolveGraphicParams(definition, params);
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
if (cachedUrl) {
return cachedUrl;
}
if (typeof document === "undefined") {
return buildFallbackPreviewUrl({ name: definition.name, size });
}
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) {
return buildFallbackPreviewUrl({ name: definition.name, size });
}
definition.render({
ctx,
params: resolvedParams,
width: size,
height: size,
});
const previewUrl = canvas.toDataURL("image/png");
graphicPreviewUrlCache.set(cacheKey, previewUrl);
return previewUrl;
}
export {
DEFAULT_GRAPHIC_SOURCE_SIZE,
ellipseGraphicDefinition,
graphicsRegistry,
polygonGraphicDefinition,
rectangleGraphicDefinition,
registerDefaultGraphics,
starGraphicDefinition,
};
export type {
GraphicDefinition,
GraphicInstance,
GraphicRenderContext,
} from "./types";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ParamDefinition, ParamValues } from "@/model/decorations/param-values";
import type { ParamDefinition, ParamValues } from "@/params";
export const DEFAULT_GRAPHIC_SOURCE_SIZE = 512;
+6 -1
View File
@@ -15,7 +15,12 @@ import { textMaskDefinition } from "@/masks/definitions/text";
import { getMaskSnapGeometry } from "@/masks/geometry";
import { snapMaskInteraction } from "@/masks/snap";
import type { ElementBounds } from "@/preview/element-bounds";
import type { CustomMaskParams, RectangleMaskParams, SplitMaskParams, TextMaskParams } from "@/model/decorations/mask";
import type {
CustomMaskParams,
RectangleMaskParams,
SplitMaskParams,
TextMaskParams,
} from "@/masks/types";
const bounds: ElementBounds = {
cx: 200,
+3 -3
View File
@@ -1,8 +1,8 @@
"use client";
import type { MaskableElement } from "@/model";
import type { Mask, MaskType, TextMask } from "@/model/decorations/mask";
import type { NumberParamDefinition, SelectParamDefinition } from "@/model/decorations/param-values";
import type { MaskableElement } from "@/timeline";
import type { Mask, MaskType, TextMask } from "@/masks/types";
import type { NumberParamDefinition, SelectParamDefinition } from "@/params";
import { masksRegistry, buildDefaultMaskInstance } from "@/masks";
import { useEditor } from "@/editor/use-editor";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+10 -2
View File
@@ -1,6 +1,14 @@
import type { ElementBounds } from "@/preview/element-bounds";
import type { CustomMaskPathPoint } from "@/model/decorations/mask";
export type { CustomMaskPathPoint } from "@/model/decorations/mask";
export interface CustomMaskPathPoint {
id: string;
x: number;
y: number;
inX: number;
inY: number;
outX: number;
outY: number;
}
export type CustomMaskHandlePart = "anchor" | "in" | "out";
+9 -3
View File
@@ -3,9 +3,15 @@ import {
MIN_MASK_DIMENSION,
} from "@/masks/dimensions";
import { computeFeatherUpdate } from "../param-update";
import type { BaseMaskParams, RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefaultContext, MaskFeatures, MaskInteractionDefinition, MaskParamUpdateArgs } from "@/masks/types";
import type { NumberParamDefinition, ParamDefinition } from "@/model/decorations/param-values";
import type {
BaseMaskParams,
MaskDefaultContext,
MaskFeatures,
MaskInteractionDefinition,
MaskParamUpdateArgs,
RectangleMaskParams,
} from "@/masks/types";
import type { NumberParamDefinition, ParamDefinition } from "@/params";
import {
getBoxMaskHandlePositions,
getBoxMaskOverlays,
@@ -1,5 +1,8 @@
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefaultContext, MaskDefinition } from "@/masks/types";
import type {
MaskDefaultContext,
MaskDefinition,
RectangleMaskParams,
} from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
+9 -3
View File
@@ -1,9 +1,15 @@
import { generateUUID } from "@/utils/id";
import type { ParamDefinition } from "@/model/decorations/param-values";
import type { ParamDefinition } from "@/params";
import { PEN_CURSOR } from "@/preview/components/cursors";
import type { ElementBounds } from "@/preview/element-bounds";
import type { CustomMask, CustomMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition, MaskHandlePosition, MaskOverlay, MaskParamUpdateArgs } from "@/masks/types";
import type {
CustomMask,
CustomMaskParams,
MaskDefinition,
MaskHandlePosition,
MaskOverlay,
MaskParamUpdateArgs,
} from "@/masks/types";
import {
buildCustomMaskPath2D,
buildCustomMaskSvgPath,
+1 -2
View File
@@ -1,5 +1,4 @@
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } from "@/masks/types";
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
+1 -2
View File
@@ -1,5 +1,4 @@
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } from "@/masks/types";
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
+1 -2
View File
@@ -1,5 +1,4 @@
import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } from "@/masks/types";
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,

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