mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b305917ac1 |
Generated
+1
-1
@@ -3820,7 +3820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.9"
|
||||
version = "0.2.10"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,4 +1,4 @@
|
||||
import type { ScalarAnimationKey } from "@/model/decorations/animations";
|
||||
import type { ScalarAnimationKey } from "@/animation/types";
|
||||
|
||||
const BEZIER_SOLVE_ITERATIONS = 20;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+21
-49
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+104
-27
@@ -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,
|
||||
+4
-4
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,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,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,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,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,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
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { NumberParamDefinition, ParamDefinition } from "@/model/decorations/param-values";
|
||||
import type { ParamDefinition, NumberParamDefinition } from "@/params";
|
||||
import {
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
|
||||
+95
-95
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+174
-174
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+11
-11
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,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[] = [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,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,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";
|
||||
|
||||
export type GraphicStrokeAlign = "inside" | "center" | "outside";
|
||||
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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,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
Reference in New Issue
Block a user