diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts new file mode 100644 index 00000000..7b9d312d --- /dev/null +++ b/apps/web/src/animation/__tests__/animated-params.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamNumericRange, + getAnimationParamValueKind, +} from "@/animation/animated-params"; + +describe("animated params", () => { + test("snaps and clamps number params", () => { + expect( + coerceAnimationParamValue({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0, + min: 0, + max: 1, + step: 0.25, + }, + value: 0.62, + }), + ).toBe(0.5); + + expect( + coerceAnimationParamValue({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0, + min: 0, + max: 1, + step: 0.25, + }, + value: 1.2, + }), + ).toBe(1); + }); + + test("rejects NaN and non-number values for number params", () => { + const param = { + key: "intensity", + label: "Intensity", + type: "number" as const, + default: 0, + min: 0, + max: 1, + step: 0.25, + }; + expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: true })).toBeNull(); + }); + + test("passthrough with step <= 0 guard", () => { + expect( + coerceAnimationParamValue({ + param: { + key: "x", + label: "X", + type: "number", + default: 0, + min: 0, + step: 0, + }, + value: 0.123, + }), + ).toBe(0.123); + }); + + test("accepts valid select values", () => { + const param = { + key: "blend", + label: "Blend", + type: "select" as const, + default: "normal", + options: [ + { value: "normal", label: "Normal" }, + { value: "multiply", label: "Multiply" }, + ], + }; + expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal"); + expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply"); + }); + + test("rejects select values outside the allowed options", () => { + expect( + coerceAnimationParamValue({ + param: { + key: "blend", + label: "Blend", + type: "select", + default: "normal", + options: [ + { value: "normal", label: "Normal" }, + { value: "multiply", label: "Multiply" }, + ], + }, + value: "screen", + }), + ).toBeNull(); + }); + + test("rejects non-string select values", () => { + const param = { + key: "blend", + label: "Blend", + type: "select" as const, + default: "normal", + options: [{ value: "normal", label: "Normal" }], + }; + expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull(); + }); + + test("boolean params accept booleans and reject other types", () => { + const param = { + key: "visible", + label: "Visible", + type: "boolean" as const, + default: true, + }; + expect(coerceAnimationParamValue({ param, value: true })).toBe(true); + expect(coerceAnimationParamValue({ param, value: false })).toBe(false); + expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull(); + }); + + test("color params accept strings and reject other types", () => { + const param = { + key: "fill", + label: "Fill", + type: "color" as const, + default: "#ffffff", + }; + expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); + expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); + }); + + test("getAnimationParamValueKind maps param type to binding kind", () => { + expect( + getAnimationParamValueKind({ + param: { + key: "n", + label: "N", + type: "number", + default: 0, + min: 0, + step: 1, + }, + }), + ).toBe("number"); + expect( + getAnimationParamValueKind({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBe("color"); + expect( + getAnimationParamValueKind({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBe("discrete"); + expect( + getAnimationParamValueKind({ + param: { + key: "s", + label: "S", + type: "select", + default: "a", + options: [{ value: "a", label: "A" }], + }, + }), + ).toBe("discrete"); + }); + + test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => { + expect( + getAnimationParamDefaultInterpolation({ + param: { + key: "n", + label: "N", + type: "number", + default: 0, + min: 0, + step: 1, + }, + }), + ).toBe("linear"); + expect( + getAnimationParamDefaultInterpolation({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBe("linear"); + expect( + getAnimationParamDefaultInterpolation({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBe("hold"); + expect( + getAnimationParamDefaultInterpolation({ + param: { + key: "s", + label: "S", + type: "select", + default: "a", + options: [{ value: "a", label: "A" }], + }, + }), + ).toBe("hold"); + }); + + test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => { + expect( + getAnimationParamNumericRange({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0.5, + min: 0, + max: 1, + step: 0.1, + }, + }), + ).toEqual({ min: 0, max: 1, step: 0.1 }); + expect( + getAnimationParamNumericRange({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBeUndefined(); + expect( + getAnimationParamNumericRange({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/animation/animated-params.ts b/apps/web/src/animation/animated-params.ts new file mode 100644 index 00000000..bc27b15d --- /dev/null +++ b/apps/web/src/animation/animated-params.ts @@ -0,0 +1,83 @@ +import { snapToStep } from "@/utils/math"; +import type { ParamDefinition } from "@/params"; +import type { DynamicAnimationPathValue, NumericSpec } from "./types"; + +export function getAnimationParamValueKind({ + param, +}: { + param: ParamDefinition; +}): "number" | "color" | "discrete" { + if (param.type === "number") { + return "number"; + } + + if (param.type === "color") { + return "color"; + } + + return "discrete"; +} + +export function getAnimationParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): "linear" | "hold" { + return param.type === "number" || param.type === "color" ? "linear" : "hold"; +} + +export function getAnimationParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): NumericSpec | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + min: param.min, + max: param.max, + step: param.step, + }; +} + +/** + * `coerceAnimationParamValue` accepts `unknown` rather than a narrow + * `DynamicAnimationPathValue` because it doubles as a runtime gate for + * untrusted inputs (persisted state, paste payloads, programmatic updates). + * The caller does not need to pre-validate; null means "this value cannot live + * on this param". + */ +export function coerceAnimationParamValue({ + param, + value, +}: { + param: ParamDefinition; + value: unknown; +}): DynamicAnimationPathValue | null { + if (param.type === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const minValue = param.min; + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); + } + + if (param.type === "color") { + return typeof value === "string" ? value : null; + } + + if (param.type === "boolean") { + return typeof value === "boolean" ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + return param.options.some((option) => option.value === value) ? value : null; +} diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts index c635bb82..e1c442f7 100644 --- a/apps/web/src/animation/effect-param-channel.ts +++ b/apps/web/src/animation/effect-param-channel.ts @@ -1,9 +1,8 @@ -import type { ParamValues } from "@/params"; -import type { Effect } from "@/effects/types"; import type { ElementAnimations, EffectParamPath, } from "@/animation/types"; +import type { ParamValues } from "@/params"; import { removeElementKeyframe } from "./keyframes"; import { resolveAnimationPathValueAtTime } from "./resolve"; @@ -56,23 +55,26 @@ export function parseEffectParamPath({ } export function resolveEffectParamsAtTime({ - effect, + effectId, + params, animations, localTime, }: { - effect: Effect; + effectId: string; + params: ParamValues; animations: ElementAnimations | undefined; localTime: number; }): ParamValues { + const safeLocalTime = Math.max(0, localTime); const resolved: ParamValues = {}; - for (const [paramKey, staticValue] of Object.entries(effect.params)) { - const path = buildEffectParamPath({ effectId: effect.id, paramKey }); + for (const [paramKey, staticValue] of Object.entries(params)) { + const path = buildEffectParamPath({ effectId, paramKey }); resolved[paramKey] = animations?.bindings[path] ? resolveAnimationPathValueAtTime({ animations, propertyPath: path, - localTime, + localTime: safeLocalTime, fallbackValue: staticValue, }) : staticValue; diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts index ebf0f940..d0bfdded 100644 --- a/apps/web/src/animation/graphic-param-channel.ts +++ b/apps/web/src/animation/graphic-param-channel.ts @@ -2,11 +2,7 @@ import type { ElementAnimations, GraphicParamPath, } from "@/animation/types"; -import type { ParamValues } from "@/params"; -import { - getGraphicDefinition, - resolveGraphicParams, -} from "@/graphics"; +import type { ParamDefinition, ParamValues } from "@/params"; import { resolveAnimationPathValueAtTime } from "./resolve"; export const GRAPHIC_PARAM_PATH_PREFIX = "params."; @@ -39,33 +35,29 @@ export function parseGraphicParamPath({ } export function resolveGraphicParamsAtTime({ - element, + params, + definitions, + animations, localTime, }: { - element: { - definitionId: string; - params: ParamValues; - animations?: ElementAnimations; - }; + params: ParamValues; + definitions: ParamDefinition[]; + animations?: ElementAnimations; localTime: number; }): ParamValues { - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const baseParams = resolveGraphicParams(definition, element.params); - const resolved: ParamValues = { ...baseParams }; + const resolved: ParamValues = { ...params }; - for (const param of definition.params) { + for (const param of definitions) { const path = buildGraphicParamPath({ paramKey: param.key }); - if (!element.animations?.bindings[path]) { + if (!animations?.bindings[path]) { continue; } resolved[param.key] = resolveAnimationPathValueAtTime({ - animations: element.animations, + animations, propertyPath: path, localTime: Math.max(0, localTime), - fallbackValue: baseParams[param.key] ?? param.default, + fallbackValue: params[param.key] ?? param.default, }); } diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts index 384f3d17..af3b910d 100644 --- a/apps/web/src/animation/index.ts +++ b/apps/web/src/animation/index.ts @@ -16,31 +16,14 @@ 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, @@ -75,15 +58,6 @@ export { resolveEffectParamsAtTime, } from "./effect-param-channel"; -export { - isAnimationPath, - coerceAnimationValueForParam, - resolveAnimationTarget, - getParamValueKind, - getParamDefaultInterpolation, - type AnimationPathDescriptor, -} from "./target-resolver"; - export { getGroupKeyframesAtTime, hasGroupKeyframeAtTime, @@ -94,3 +68,7 @@ export { type EasingMode, getEasingModeForKind, } from "./binding-values"; + +export { + isAnimationPath, +} from "./path"; diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts index 277bfbb7..6bab66c4 100644 --- a/apps/web/src/animation/interpolation.ts +++ b/apps/web/src/animation/interpolation.ts @@ -8,13 +8,13 @@ import type { 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, diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts index 231aa5f2..e65db881 100644 --- a/apps/web/src/animation/keyframe-query.ts +++ b/apps/web/src/animation/keyframe-query.ts @@ -13,7 +13,7 @@ import { getChannelValueAtTime, getScalarSegmentInterpolation, } from "./interpolation"; -import { isAnimationPath } from "./target-resolver"; +import { isAnimationPath } from "./path"; function getBindingFallbackValue({ channel, diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts index 12dc777d..ff0cef8f 100644 --- a/apps/web/src/animation/keyframes.ts +++ b/apps/web/src/animation/keyframes.ts @@ -4,7 +4,6 @@ import type { AnimationChannel, AnimationInterpolation, AnimationPath, - AnimationPropertyPath, AnimationValue, DiscreteAnimationChannel, DiscreteAnimationKey, @@ -14,7 +13,6 @@ import type { ScalarCurveKeyframePatch, ScalarSegmentType, } from "@/animation/types"; -import { generateUUID } from "@/utils/id"; import { cloneAnimationBinding, createAnimationBinding, @@ -31,10 +29,7 @@ import { getScalarSegmentInterpolation, normalizeChannel, } from "./interpolation"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, -} from "./property-registry"; +import { generateUUID } from "@/utils/id"; function isNearlySameTime({ leftTime, @@ -527,47 +522,6 @@ 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, diff --git a/apps/web/src/animation/path.ts b/apps/web/src/animation/path.ts new file mode 100644 index 00000000..69e5e228 --- /dev/null +++ b/apps/web/src/animation/path.ts @@ -0,0 +1,22 @@ +import type { AnimationPath, AnimationPropertyPath } from "@/animation/types"; +import { ANIMATION_PROPERTY_PATHS } from "./types"; +import { isEffectParamPath } from "./effect-param-channel"; +import { isGraphicParamPath } from "./graphic-param-channel"; + +const ANIMATION_PROPERTY_PATH_SET = new Set(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) + ); +} diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts index 1b51c126..19685cea 100644 --- a/apps/web/src/animation/resolve.ts +++ b/apps/web/src/animation/resolve.ts @@ -1,12 +1,8 @@ import type { - AnimationColorPropertyPath, - AnimationNumericPropertyPath, AnimationPath, - AnimationPropertyPath, AnimationValueForPath, ElementAnimations, } from "@/animation/types"; -import type { Transform } from "@/rendering"; import { type AnimationComponentValue, composeAnimationValue, @@ -37,107 +33,6 @@ export function getElementLocalTime({ return localTime; } -export function resolveTransformAtTime({ - baseTransform, - animations, - localTime, -}: { - baseTransform: Transform; - animations: ElementAnimations | undefined; - localTime: number; -}): Transform { - const safeLocalTime = Math.max(0, localTime); - return { - position: { - x: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionX", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.x, - }), - y: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionY", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.y, - }), - }, - scaleX: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleX", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleX, - }), - scaleY: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleY", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleY, - }), - rotate: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.rotate", - localTime: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath: "opacity", - localTime: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationNumericPropertyPath; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationColorPropertyPath; - localTime: number; -}): string { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} - export function resolveAnimationPathValueAtTime({ animations, propertyPath, diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts index 3afddfa1..7e241994 100644 --- a/apps/web/src/animation/types.ts +++ b/apps/web/src/animation/types.ts @@ -1,5 +1,3 @@ -import type { ParamValues } from "@/params"; - export const ANIMATION_PROPERTY_PATHS = [ "transform.positionX", "transform.positionY", @@ -50,7 +48,13 @@ export interface AnimationPropertyValueMap { "background.offsetY": number; "background.cornerRadius": number; } -export type DynamicAnimationPathValue = ParamValues[string]; +export type DynamicAnimationPathValue = number | string | boolean; + +export interface NumericSpec { + min?: number; + max?: number; + step?: number; +} export type AnimationValueForPath = TPath extends AnimationPropertyPath ? AnimationPropertyValueMap[TPath] diff --git a/apps/web/src/animation/values.ts b/apps/web/src/animation/values.ts new file mode 100644 index 00000000..7733ec4c --- /dev/null +++ b/apps/web/src/animation/values.ts @@ -0,0 +1,61 @@ +import type { + AnimationColorPropertyPath, + AnimationNumericPropertyPath, + ElementAnimations, +} from "./types"; +import { resolveAnimationPathValueAtTime } from "./resolve"; + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath: "opacity", + localTime: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +export function resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationNumericPropertyPath; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationColorPropertyPath; + localTime: number; +}): string { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts index e05b49fa..c19bd526 100644 --- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -1,7 +1,6 @@ import { EditorCore } from "@/core"; import { getKeyframeAtTime, - resolveAnimationTarget, updateScalarKeyframeCurve, upsertPathKeyframe, } from "@/animation"; @@ -9,6 +8,7 @@ import { Command, type CommandResult } from "@/commands/base-command"; import type { KeyframeClipboardItem } from "@/clipboard"; import type { SceneTracks, TimelineElement } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import { generateUUID } from "@/utils/id"; function pasteKeyframesIntoElement({ diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts index ed917406..07abe2be 100644 --- a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts @@ -2,12 +2,12 @@ import { EditorCore } from "@/core"; import { hasKeyframesForPath, removeElementKeyframe, - resolveAnimationTarget, } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath, AnimationValue } from "@/animation/types"; import type { SceneTracks, TimelineElement } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; function removeKeyframeAndPersist({ element, diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts index ccbdbc5d..aa04e2bc 100644 --- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -1,9 +1,10 @@ import { EditorCore } from "@/core"; -import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation"; +import { retimeElementKeyframe } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; export class RetimeKeyframeCommand extends Command { private savedState: SceneTracks | null = null; diff --git a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts index 4a3db39d..51f10ee9 100644 --- a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts +++ b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts @@ -1,10 +1,10 @@ import { EditorCore } from "@/core"; import { - resolveAnimationTarget, updateScalarKeyframeCurve, } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationPath, ScalarCurveKeyframePatch, diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index eae8f039..7bd1b2e5 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -2,11 +2,11 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; import { buildEffectParamPath, - resolveAnimationTarget, upsertPathKeyframe, } from "@/animation"; import { updateElementInSceneTracks } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationInterpolation } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts index 30514146..15c18bfc 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -1,8 +1,9 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; -import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation"; +import { upsertPathKeyframe } from "@/animation"; import { updateElementInSceneTracks } from "@/timeline"; import type { SceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationPath, AnimationInterpolation, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index 734b514f..80fc171b 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -1,95 +1,95 @@ -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; -}) { - 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, +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import { upsertElementKeyframe } from "@/timeline/animation-properties"; + +export function useKeyframedColorProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + resolvedColor, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedColor: string; + buildBaseUpdates: ({ value }: { value: string }) => Partial; +}) { + const editor = useEditor(); + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const onChange = ({ color }: { color: string }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: color, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], + }); + }; + + const onChangeEnd = () => editor.timeline.commitPreview(); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, + ], + }); + }; + + return { + isKeyframedAtTime, + hasAnimatedKeyframes, + keyframeIdAtTime, + onChange, + onChangeEnd, + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 9dd39768..32222b7a 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -1,174 +1,174 @@ -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; - 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, +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import { upsertElementKeyframe } from "@/timeline/animation-properties"; +import { snapToStep } from "@/utils/math"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedNumberProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + displayValue, + parse, + valueAtPlayhead, + step, + buildBaseUpdates, + buildAdditionalKeyframes, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + displayValue: string; + parse: (input: string) => number | null; + valueAtPlayhead: number; + step?: number; + buildBaseUpdates: ({ value }: { value: number }) => Partial; + buildAdditionalKeyframes?: ({ + value, + }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; +}) { + const editor = useEditor(); + const snapValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; + const updatedAnimations = [ + { propertyPath, value: nextValue }, + ...additionalKeyframes, + ].reduce( + (currentAnimations, keyframe) => + upsertElementKeyframe({ + animations: currentAnimations, + propertyPath: keyframe.propertyPath, + time: localTime, + value: keyframe.value, + }), + animations, + ); + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { animations: updatedAnimations }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + const propertyDraft = usePropertyDraft({ + displayValue, + parse: (input) => { + const parsedValue = parse(input); + return parsedValue === null ? null : snapValue(parsedValue); + }, + onPreview: (value) => previewValue({ value }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: snapValue(valueAtPlayhead), + }, + ], + }); + }; + + const commitValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: nextValue }, + ...additionalKeyframes.map((keyframe) => ({ + trackId, + elementId, + propertyPath: keyframe.propertyPath, + time: localTime, + value: keyframe.value, + })), + ], + }); + return; + } + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId, + patch: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + return { + ...propertyDraft, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitValue, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index b0b6f01a..18e027c7 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -3,16 +3,18 @@ import { useEditor } from "@/editor/use-editor"; import { buildGraphicParamPath, - coerceAnimationValueForParam, getKeyframeAtTime, - getParamDefaultInterpolation, - getParamValueKind, hasKeyframesForPath, upsertPathKeyframe, } from "@/animation"; import type { ElementAnimations, } from "@/animation/types"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamValueKind, +} from "@/animation/animated-params"; import type { ParamDefinition } from "@/params"; import type { TimelineElement } from "@/timeline"; @@ -79,12 +81,12 @@ export function useKeyframedParamProperty({ propertyPath, time: localTime, value, - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ + kind: getAnimationParamValueKind({ param }), + defaultInterpolation: getAnimationParamDefaultInterpolation({ param, }), coerceValue: ({ value: nextValue }) => - coerceAnimationValueForParam({ + coerceAnimationParamValue({ param, value: nextValue, }), diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 6f54c5aa..a2bf7451 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -24,9 +24,9 @@ import type { } 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 { diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx index 60599772..e0ae0236 100644 --- a/apps/web/src/graphics/components/graphic-tab.tsx +++ b/apps/web/src/graphics/components/graphic-tab.tsx @@ -1,238 +1,241 @@ -"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 ( -
-
- - {definition.name} - - - - {shapeParams.map((param) => ( - - ))} - - -
- {hasStrokeParams && } -
- ); -} - -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 ( -
- { - event.stopPropagation(); - toggleStroke(); - }} - > - - - } - > - Stroke - - - - {strokeParams.map((param) => ( - - ))} - - -
- ); -} - -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 ( - - ); -} +"use client"; + +import { useRef } from "react"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { + useKeyframedParamProperty, + type KeyframedParamPropertyResult, +} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; +import type { ParamDefinition, ParamValues } from "@/params"; +import type { GraphicElement } from "@/timeline"; +import { + graphicsRegistry, + registerDefaultGraphics, + resolveGraphicElementParamsAtTime, +} from "@/graphics"; +import { useElementPreview } from "@/timeline/hooks/use-element-preview"; +import { useEditor } from "@/editor/use-editor"; +import { + Section, + SectionContent, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +registerDefaultGraphics(); + +const DEFAULT_STROKE_WIDTH = 2; + +export function GraphicTab({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicElementParamsAtTime({ + element: liveElement, + localTime, + }); + + const shapeParams = definition.params.filter((p) => p.group !== "stroke"); + const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); + + return ( +
+
+ + {definition.name} + + + + {shapeParams.map((param) => ( + + ))} + + +
+ {hasStrokeParams && } +
+ ); +} + +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 ( +
+ { + event.stopPropagation(); + toggleStroke(); + }} + > + + + } + > + Stroke + + + + {strokeParams.map((param) => ( + + ))} + + +
+ ); +} + +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 ( + + ); +} diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts index c69a4ab0..3f277d40 100644 --- a/apps/web/src/graphics/index.ts +++ b/apps/web/src/graphics/index.ts @@ -1,3 +1,5 @@ +import { resolveGraphicParamsAtTime } from "@/animation"; +import type { ElementAnimations } from "@/animation/types"; import { buildDefaultParamValues } from "@/params/registry"; import type { ParamValues } from "@/params"; import { graphicsRegistry } from "./registry"; @@ -68,6 +70,28 @@ export function resolveGraphicParams( }; } +export function resolveGraphicElementParamsAtTime({ + element, + localTime, +}: { + element: { + definitionId: string; + params: ParamValues; + animations?: ElementAnimations; + }; + localTime: number; +}): ParamValues { + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + return resolveGraphicParamsAtTime({ + params: resolveGraphicParams(definition, element.params), + definitions: definition.params, + animations: element.animations, + localTime, + }); +} + export function buildGraphicPreviewUrl({ definitionId, params, diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx index bc27c724..1d599b3b 100644 --- a/apps/web/src/preview/components/text-edit-overlay.tsx +++ b/apps/web/src/preview/components/text-edit-overlay.tsx @@ -7,8 +7,8 @@ import type { TextElement } from "@/timeline"; import { DEFAULTS } from "@/timeline/defaults"; import { getElementLocalTime, - resolveTransformAtTime, } from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { resolveTextLayout } from "@/text/primitives"; export function TextEditOverlay({ diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts index e9d251e3..67f677f6 100644 --- a/apps/web/src/preview/controllers/transform-handle-controller.ts +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -20,11 +20,11 @@ import { isVisualElement } from "@/timeline/element-utils"; import { getElementLocalTime, hasKeyframesForPath, - resolveTransformAtTime, setChannel, } from "@/animation"; import type { ElementAnimations } from "@/animation/types"; import type { Transform } from "@/rendering"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import type { ElementRef, SceneTracks, diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts index ac556181..0afc6e4e 100644 --- a/apps/web/src/preview/element-bounds.ts +++ b/apps/web/src/preview/element-bounds.ts @@ -1,308 +1,308 @@ -import type { SceneTracks, TimelineElement } from "@/timeline"; -import type { MediaAsset } from "@/media/types"; -import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size"; -import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; -import { measureTextElement } from "@/text/measure-element"; -import { - getElementLocalTime, - resolveTransformAtTime, -} from "@/animation"; - -export interface ElementBounds { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; -} - -export interface ElementWithBounds { - trackId: string; - elementId: string; - element: TimelineElement; - bounds: ElementBounds; -} - -function getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth, - sourceHeight, - transform, -}: { - canvasWidth: number; - canvasHeight: number; - sourceWidth: number; - sourceHeight: number; - transform: { - scaleX: number; - scaleY: number; - position: { x: number; y: number }; - rotate: number; - }; -}): ElementBounds { - const containScale = Math.min( - canvasWidth / sourceWidth, - canvasHeight / sourceHeight, - ); - const scaledWidth = sourceWidth * containScale * transform.scaleX; - const scaledHeight = sourceHeight * containScale * transform.scaleY; - const cx = canvasWidth / 2 + transform.position.x; - const cy = canvasHeight / 2 + transform.position.y; - - return { - cx, - cy, - width: scaledWidth, - height: scaledHeight, - rotation: transform.rotate, - }; -} - -function getTransformedRectBounds({ - canvasWidth, - canvasHeight, - rect, - transform, -}: { - canvasWidth: number; - canvasHeight: number; - rect: { left: number; top: number; width: number; height: number }; - transform: { - scaleX: number; - scaleY: number; - position: { x: number; y: number }; - rotate: number; - }; -}): ElementBounds { - const localCenterX = rect.left + rect.width / 2; - const localCenterY = rect.top + rect.height / 2; - const scaledCenterX = localCenterX * transform.scaleX; - const scaledCenterY = localCenterY * transform.scaleY; - const rotationRad = (transform.rotate * Math.PI) / 180; - const cos = Math.cos(rotationRad); - const sin = Math.sin(rotationRad); - return { - cx: - canvasWidth / 2 + - transform.position.x + - scaledCenterX * cos - - scaledCenterY * sin, - cy: - canvasHeight / 2 + - transform.position.y + - scaledCenterX * sin + - scaledCenterY * cos, - width: rect.width * transform.scaleX, - height: rect.height * transform.scaleY, - rotation: transform.rotate, - }; -} - -/** - * Bounds policy: bounds reflect base content geometry (text glyphs + background, - * sticker/image/video content area) and base transform. Post-effect spill (blur, - * glow) and mask-clipped regions are intentionally excluded — handles manipulate - * the canonical element geometry, not visual effect output. - */ -function getElementBounds({ - element, - canvasSize, - mediaAsset, - localTime, -}: { - element: TimelineElement; - canvasSize: { width: number; height: number }; - mediaAsset?: MediaAsset | null; - localTime: number; -}): ElementBounds | null { - if (element.type === "audio" || element.type === "effect") return null; - if ("hidden" in element && element.hidden) return null; - - const { width: canvasWidth, height: canvasHeight } = canvasSize; - - if (element.type === "video" || element.type === "image") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - const sourceWidth = mediaAsset?.width ?? canvasWidth; - const sourceHeight = mediaAsset?.height ?? canvasHeight; - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth, - sourceHeight, - transform, - }); - } - - if (element.type === "sticker") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, - sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, - transform, - }); - } - - if (element.type === "graphic") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, - sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, - transform, - }); - } - - if (element.type === "text") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - - const measured = measureTextElement({ - element, - canvasHeight, - localTime, - ctx, - }); - - return getTransformedRectBounds({ - canvasWidth, - canvasHeight, - rect: measured.visualRect, - transform, - }); - } - - return null; -} - -export const ROTATION_HANDLE_OFFSET = 24; - -export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -export type Edge = "right" | "left" | "bottom"; - -export function getCornerPosition({ - bounds, - corner, -}: { - bounds: ElementBounds; - corner: Corner; -}): { x: number; y: number } { - const halfW = bounds.width / 2; - const halfH = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; - const localY = - corner === "top-left" || corner === "top-right" ? -halfH : halfH; - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getEdgeHandlePosition({ - bounds, - edge, -}: { - bounds: ElementBounds; - edge: Edge; -}): { x: number; y: number } { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0; - const localY = edge === "bottom" ? halfHeight : 0; - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, -}: { - tracks: SceneTracks; - currentTime: number; - canvasSize: { width: number; height: number }; - mediaAssets: MediaAsset[]; -}): ElementWithBounds[] { - const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); - const orderedTracks = [ - ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), - ...(!tracks.main.hidden ? [tracks.main] : []), - ].reverse(); - - const result: ElementWithBounds[] = []; - - for (const track of orderedTracks) { - const elements = track.elements - .filter((element) => !("hidden" in element && element.hidden)) - .filter( - (element) => - currentTime >= element.startTime && - currentTime < element.startTime + element.duration, - ) - .slice() - .sort((a, b) => { - if (a.startTime !== b.startTime) return a.startTime - b.startTime; - return a.id.localeCompare(b.id); - }); - - for (const element of elements) { - const localTime = getElementLocalTime({ - timelineTime: currentTime, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const mediaAsset = - element.type === "video" || element.type === "image" - ? mediaMap.get(element.mediaId) - : undefined; - const bounds = getElementBounds({ - element, - canvasSize, - mediaAsset, - localTime, - }); - if (bounds) { - result.push({ - trackId: track.id, - elementId: element.id, - element, - bounds, - }); - } - } - } - - return result; -} +import type { SceneTracks, TimelineElement } from "@/timeline"; +import type { MediaAsset } from "@/media/types"; +import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size"; +import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; +import { measureTextElement } from "@/text/measure-element"; +import { + getElementLocalTime, +} from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; + +export interface ElementBounds { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; +} + +export interface ElementWithBounds { + trackId: string; + elementId: string; + element: TimelineElement; + bounds: ElementBounds; +} + +function getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + sourceWidth: number; + sourceHeight: number; + transform: { + scaleX: number; + scaleY: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const containScale = Math.min( + canvasWidth / sourceWidth, + canvasHeight / sourceHeight, + ); + const scaledWidth = sourceWidth * containScale * transform.scaleX; + const scaledHeight = sourceHeight * containScale * transform.scaleY; + const cx = canvasWidth / 2 + transform.position.x; + const cy = canvasHeight / 2 + transform.position.y; + + return { + cx, + cy, + width: scaledWidth, + height: scaledHeight, + rotation: transform.rotate, + }; +} + +function getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + rect: { left: number; top: number; width: number; height: number }; + transform: { + scaleX: number; + scaleY: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const localCenterX = rect.left + rect.width / 2; + const localCenterY = rect.top + rect.height / 2; + const scaledCenterX = localCenterX * transform.scaleX; + const scaledCenterY = localCenterY * transform.scaleY; + const rotationRad = (transform.rotate * Math.PI) / 180; + const cos = Math.cos(rotationRad); + const sin = Math.sin(rotationRad); + return { + cx: + canvasWidth / 2 + + transform.position.x + + scaledCenterX * cos - + scaledCenterY * sin, + cy: + canvasHeight / 2 + + transform.position.y + + scaledCenterX * sin + + scaledCenterY * cos, + width: rect.width * transform.scaleX, + height: rect.height * transform.scaleY, + rotation: transform.rotate, + }; +} + +/** + * Bounds policy: bounds reflect base content geometry (text glyphs + background, + * sticker/image/video content area) and base transform. Post-effect spill (blur, + * glow) and mask-clipped regions are intentionally excluded — handles manipulate + * the canonical element geometry, not visual effect output. + */ +function getElementBounds({ + element, + canvasSize, + mediaAsset, + localTime, +}: { + element: TimelineElement; + canvasSize: { width: number; height: number }; + mediaAsset?: MediaAsset | null; + localTime: number; +}): ElementBounds | null { + if (element.type === "audio" || element.type === "effect") return null; + if ("hidden" in element && element.hidden) return null; + + const { width: canvasWidth, height: canvasHeight } = canvasSize; + + if (element.type === "video" || element.type === "image") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const sourceWidth = mediaAsset?.width ?? canvasWidth; + const sourceHeight = mediaAsset?.height ?? canvasHeight; + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform, + }); + } + + if (element.type === "sticker") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, + sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, + transform, + }); + } + + if (element.type === "graphic") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, + sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, + transform, + }); + } + + if (element.type === "text") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + const measured = measureTextElement({ + element, + canvasHeight, + localTime, + ctx, + }); + + return getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect: measured.visualRect, + transform, + }); + } + + return null; +} + +export const ROTATION_HANDLE_OFFSET = 24; + +export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +export type Edge = "right" | "left" | "bottom"; + +export function getCornerPosition({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): { x: number; y: number } { + const halfW = bounds.width / 2; + const halfH = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; + const localY = + corner === "top-left" || corner === "top-right" ? -halfH : halfH; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getEdgeHandlePosition({ + bounds, + edge, +}: { + bounds: ElementBounds; + edge: Edge; +}): { x: number; y: number } { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0; + const localY = edge === "bottom" ? halfHeight : 0; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, +}: { + tracks: SceneTracks; + currentTime: number; + canvasSize: { width: number; height: number }; + mediaAssets: MediaAsset[]; +}): ElementWithBounds[] { + const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); + const orderedTracks = [ + ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), + ...(!tracks.main.hidden ? [tracks.main] : []), + ].reverse(); + + const result: ElementWithBounds[] = []; + + for (const track of orderedTracks) { + const elements = track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .filter( + (element) => + currentTime >= element.startTime && + currentTime < element.startTime + element.duration, + ) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); + + for (const element of elements) { + const localTime = getElementLocalTime({ + timelineTime: currentTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const mediaAsset = + element.type === "video" || element.type === "image" + ? mediaMap.get(element.mediaId) + : undefined; + const bounds = getElementBounds({ + element, + canvasSize, + mediaAsset, + localTime, + }); + if (bounds) { + result.push({ + trackId: track.id, + elementId: element.id, + element, + bounds, + }); + } + } + } + + return result; +} diff --git a/apps/web/src/rendering/NOTES.md b/apps/web/src/rendering/NOTES.md new file mode 100644 index 00000000..38055c14 --- /dev/null +++ b/apps/web/src/rendering/NOTES.md @@ -0,0 +1,27 @@ +# Notes + +## `Transform` is misplaced + +`Transform` is currently defined in `apps/web/src/rendering/index.ts`. It's +exported from the rendering domain because rendering happens to be one of its +consumers — but every other domain that touches scene geometry consumes it too +(`@/timeline`, `@/preview`, `@/animation/values`, `@/text`, etc.). It's not +"of" rendering any more than `Vector2` would be "of" math. + +`Transform` is closer to a primitive than a domain concept. It's infrastructure: +a small value type with no behavior, no dependencies, no domain-specific +invariants. Anything that wants to position something on a 2D canvas needs it. + +The codebase should be refactored so it's defined lower-level, rather than +sitting in a domain. Candidate homes: + +- `apps/web/src/primitives/transform.ts` (new dir for these value types) +- `apps/web/src/geometry/transform.ts` if `Vector2` and friends end up there +- `apps/web/src/rendering/transform.ts` is acceptable only if `@/rendering` +becomes a primitives folder rather than a domain (it's borderline today — +it also exports `BlendMode`, which has the same problem). + +Side effect of fixing this: `@/rendering/animation-values.ts` (which exists +only because `Transform` lives next to it) can move into `@/animation/values.ts` +alongside the other resolve-at-time helpers, since `Transform` would no longer +pull in a domain dependency. \ No newline at end of file diff --git a/apps/web/src/rendering/animation-values.ts b/apps/web/src/rendering/animation-values.ts new file mode 100644 index 00000000..8e8b4528 --- /dev/null +++ b/apps/web/src/rendering/animation-values.ts @@ -0,0 +1,49 @@ +import type { ElementAnimations } from "@/animation/types"; +import { resolveAnimationPathValueAtTime } from "@/animation"; +import type { Transform } from "./index"; + +export function resolveTransformAtTime({ + baseTransform, + animations, + localTime, +}: { + baseTransform: Transform; + animations: ElementAnimations | undefined; + localTime: number; +}): Transform { + const safeLocalTime = Math.max(0, localTime); + return { + position: { + x: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionX", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.x, + }), + y: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionY", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.y, + }), + }, + scaleX: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleX", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleX, + }), + scaleY: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleY", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleY, + }), + rotate: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.rotate", + localTime: safeLocalTime, + fallbackValue: baseTransform.rotate, + }), + }; +} diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx index 3e0db46c..48b1d0e5 100644 --- a/apps/web/src/rendering/components/blending-tab.tsx +++ b/apps/web/src/rendering/components/blending-tab.tsx @@ -27,7 +27,7 @@ import { RainDropIcon } from "@hugeicons/core-free-icons"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/animation"; +import { resolveOpacityAtTime } from "@/animation/values"; import { DEFAULTS } from "@/timeline/defaults"; import { isPropertyAtDefault } from "./transform-tab"; diff --git a/apps/web/src/rendering/components/transform-tab.tsx b/apps/web/src/rendering/components/transform-tab.tsx index 60d4c7df..f4d5a183 100644 --- a/apps/web/src/rendering/components/transform-tab.tsx +++ b/apps/web/src/rendering/components/transform-tab.tsx @@ -20,8 +20,8 @@ import { import { getGroupKeyframesAtTime, hasGroupKeyframeAtTime, - resolveTransformAtTime, } from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { DEFAULTS } from "@/timeline/defaults"; import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 5495ba3b..809f7103 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -1,11 +1,5 @@ import { mediaTimeToSeconds } from "opencut-wasm"; -import { - getElementLocalTime, - resolveColorAtTime, - resolveGraphicParamsAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "@/animation"; +import { getElementLocalTime } from "@/animation"; import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel"; import { buildGaussianBlurPasses, @@ -14,11 +8,16 @@ import { import { effectsRegistry, resolveEffectPasses } from "@/effects"; import type { Effect, EffectPass } from "@/effects/types"; import { getSourceTimeAtClipTime } from "@/retime"; -import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; +import { + DEFAULT_GRAPHIC_SOURCE_SIZE, + resolveGraphicElementParamsAtTime, +} from "@/graphics"; import { getTextMeasurementContext, measureTextElement, } from "@/text/measure-element"; +import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { videoCache } from "@/services/video-cache/service"; import type { CanvasRenderer } from "./canvas-renderer"; import type { AnyBaseNode } from "./nodes/base-node"; @@ -113,7 +112,8 @@ function resolveEffectPassGroups({ .filter((effect) => effect.enabled) .map((effect) => { const resolvedParams = resolveEffectParamsAtTime({ - effect, + effectId: effect.id, + params: effect.params, animations, localTime, }); @@ -304,7 +304,7 @@ function resolveGraphicNode({ return { ...visualState, - resolvedParams: resolveGraphicParamsAtTime({ + resolvedParams: resolveGraphicElementParamsAtTime({ element: node.params, localTime: visualState.localTime, }), diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx index bf14efba..0f0b274a 100644 --- a/apps/web/src/text/components/text-tab.tsx +++ b/apps/web/src/text/components/text-tab.tsx @@ -30,7 +30,10 @@ import { useKeyframedNumberProperty } from "@/components/editor/panels/propertie import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; import { isPropertyAtDefault } from "@/rendering/components/transform-tab"; -import { resolveColorAtTime, resolveNumberAtTime } from "@/animation"; +import { + resolveColorAtTime, + resolveNumberAtTime, +} from "@/animation/values"; import { HugeiconsIcon } from "@hugeicons/react"; import { MinusSignIcon, diff --git a/apps/web/src/text/measure-element.ts b/apps/web/src/text/measure-element.ts index 3a49696f..ec14421b 100644 --- a/apps/web/src/text/measure-element.ts +++ b/apps/web/src/text/measure-element.ts @@ -1,7 +1,7 @@ import { CORNER_RADIUS_MIN } from "@/text/background"; -import { resolveNumberAtTime } from "@/animation"; import { DEFAULTS } from "@/timeline/defaults"; import type { TextBackground, TextElement } from "@/timeline"; +import { resolveNumberAtTime } from "@/animation/values"; import { getTextVisualRect, } from "./layout"; diff --git a/apps/web/src/animation/property-registry.ts b/apps/web/src/timeline/animation-properties.ts similarity index 90% rename from apps/web/src/animation/property-registry.ts rename to apps/web/src/timeline/animation-properties.ts index aacb9972..31479a58 100644 --- a/apps/web/src/animation/property-registry.ts +++ b/apps/web/src/timeline/animation-properties.ts @@ -3,10 +3,15 @@ import type { AnimationInterpolation, AnimationPropertyPath, AnimationValue, + ElementAnimations, + NumericSpec, } from "@/animation/types"; -import { parseColorToLinearRgba } from "./binding-values"; -import type { TimelineElement } from "@/timeline"; +import { upsertPathKeyframe } from "@/animation"; +import { parseColorToLinearRgba } from "@/animation/binding-values"; +import { isAnimationPropertyPath } from "@/animation/path"; import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; +import { snapToStep } from "@/utils/math"; +import type { TimelineElement } from "@/timeline"; import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, @@ -17,13 +22,6 @@ 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; @@ -313,12 +311,6 @@ const ANIMATION_PROPERTY_REGISTRY: Record< }), }; -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - export function getAnimationPropertyDefinition({ propertyPath, }: { @@ -349,6 +341,7 @@ export function getElementBaseValueForProperty({ if (!definition.supportsElement({ element })) { return null; } + return definition.getValue({ element }); } @@ -366,6 +359,7 @@ export function withElementBaseValueForProperty({ if (coercedValue === null || !definition.supportsElement({ element })) { return element; } + return definition.setValue({ element, value: coercedValue }); } @@ -388,3 +382,44 @@ 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, + }), + }); +} diff --git a/apps/web/src/animation/timeline-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts similarity index 100% rename from apps/web/src/animation/timeline-snap-points.ts rename to apps/web/src/timeline/animation-snap-points.ts diff --git a/apps/web/src/animation/target-resolver.ts b/apps/web/src/timeline/animation-targets.ts similarity index 64% rename from apps/web/src/animation/target-resolver.ts rename to apps/web/src/timeline/animation-targets.ts index 32e7917c..f9d53fcc 100644 --- a/apps/web/src/animation/target-resolver.ts +++ b/apps/web/src/timeline/animation-targets.ts @@ -3,30 +3,32 @@ import type { AnimationInterpolation, AnimationPath, AnimationValue, + NumericSpec, } from "@/animation/types"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamNumericRange, + getAnimationParamValueKind, +} from "@/animation/animated-params"; import { parseEffectParamPath, - isEffectParamPath, } from "@/animation/effect-param-channel"; import { - isGraphicParamPath, parseGraphicParamPath, } from "@/animation/graphic-param-channel"; -import type { ParamDefinition } from "@/params"; import { effectsRegistry, registerDefaultEffects } from "@/effects"; import { getGraphicDefinition } from "@/graphics"; +import type { ParamDefinition } from "@/params"; import type { TimelineElement } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; -import { snapToStep } from "@/utils/math"; +import { isAnimationPropertyPath } from "@/animation/path"; import { coerceAnimationValueForProperty, getAnimationPropertyDefinition, getElementBaseValueForProperty, - isAnimationPropertyPath, - type NumericSpec, withElementBaseValueForProperty, -} from "./property-registry"; -import { parseColorToLinearRgba } from "./binding-values"; +} from "./animation-properties"; export interface AnimationPathDescriptor { kind: AnimationBindingKind; @@ -37,79 +39,16 @@ export interface AnimationPathDescriptor { setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; } -export function getParamValueKind({ - param, -}: { - param: ParamDefinition; -}): AnimationBindingKind { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): AnimationInterpolation { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -function getParamNumericRange({ +// Number/discrete bindings expose a single component named "value" +// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry +// numeric ranges yet — revisit when one does. +function paramNumericRanges({ param, }: { param: ParamDefinition; }): Partial> | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - value: { - min: param.min, - max: param.max, - step: param.step, - }, - }; -} - -export function coerceAnimationValueForParam({ - param, - value, -}: { - param: ParamDefinition; - value: AnimationValue; -}): number | string | boolean | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; + const range = getAnimationParamNumericRange({ param }); + return range ? { value: range } : undefined; } function buildGraphicParamDescriptor({ @@ -132,13 +71,13 @@ function buildGraphicParamDescriptor({ } return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), + kind: getAnimationParamValueKind({ param }), + defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), + numericRanges: paramNumericRanges({ param }), + coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), getBaseValue: () => element.params[param.key] ?? param.default, setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); + const coercedValue = coerceAnimationParamValue({ param, value }); if (coercedValue === null) { return element; } @@ -180,13 +119,13 @@ function buildEffectParamDescriptor({ } return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), + kind: getAnimationParamValueKind({ param }), + defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), + numericRanges: paramNumericRanges({ param }), + coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), getBaseValue: () => effect.params[param.key] ?? param.default, setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); + const coercedValue = coerceAnimationParamValue({ param, value }); if (coercedValue === null) { return element; } @@ -210,16 +149,6 @@ function buildEffectParamDescriptor({ }; } -export function isAnimationPath( - propertyPath: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - export function resolveAnimationTarget({ element, path, diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts index 4ef559ea..44df5aaf 100644 --- a/apps/web/src/timeline/audio-state.ts +++ b/apps/web/src/timeline/audio-state.ts @@ -1,5 +1,5 @@ import { hasKeyframesForPath } from "@/animation/keyframe-query"; -import { resolveNumberAtTime } from "@/animation/resolve"; +import { resolveNumberAtTime } from "@/animation/values"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; import type { TimelineElement } from "./types"; const DEFAULT_STEP_SECONDS = 1 / 60; diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts index 1071c6dd..35ee9cca 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -19,7 +19,7 @@ import { import { getBookmarkSnapPoints } from "../snap-source"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { Bookmark } from "@/timeline"; export interface BookmarkDragState { diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx index 1dbb3d1a..1e47dcc8 100644 --- a/apps/web/src/timeline/components/audio-tab.tsx +++ b/apps/web/src/timeline/components/audio-tab.tsx @@ -1,151 +1,151 @@ -import { Button } from "@/components/ui/button"; -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { isSourceAudioSeparated } from "@/timeline/audio-separation"; -import { DEFAULTS } from "@/timeline/defaults"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - isNearlyEqual, - snapToStep, -} from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/timeline"; -import { resolveNumberAtTime } from "@/animation"; -import { useEditor } from "@/editor/use-editor"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { VolumeHighIcon } from "@hugeicons/core-free-icons"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; - -const VOLUME_STEP = 0.1; -const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedVolume = resolveNumberAtTime({ - baseValue: element.volume ?? DEFAULTS.element.volume, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - const volume = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "volume", - localTime, - isPlayheadWithinElementRange, - displayValue: formatNumberForDisplay({ - value: resolvedVolume, - fractionDigits: VOLUME_FRACTION_DIGITS, - }), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ - value: snapToStep({ value: parsed, step: VOLUME_STEP }), - min: VOLUME_DB_MIN, - max: VOLUME_DB_MAX, - }); - }, - valueAtPlayhead: resolvedVolume, - step: VOLUME_STEP, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - const isSeparated = - element.type === "video" && isSourceAudioSeparated({ element }); - - return ( - <> - {isSeparated && ( -
-

Audio has been separated.

- -
- )} -
- - Audio - - - - - } - > - } - value={volume.displayValue} - onFocus={volume.onFocus} - onChange={volume.onChange} - onBlur={volume.onBlur} - dragSensitivity="slow" - scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} - onScrub={volume.scrubTo} - onScrubEnd={volume.commitScrub} - onReset={() => - volume.commitValue({ - value: DEFAULTS.element.volume, - }) - } - isDefault={isDefault} - suffix="dB" - /> - - - -
- - ); -} +import { Button } from "@/components/ui/button"; +import { NumberField } from "@/components/ui/number-field"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { isSourceAudioSeparated } from "@/timeline/audio-separation"; +import { DEFAULTS } from "@/timeline/defaults"; +import { + clamp, + formatNumberForDisplay, + getFractionDigitsForStep, + isNearlyEqual, + snapToStep, +} from "@/utils/math"; +import type { AudioElement, VideoElement } from "@/timeline"; +import { resolveNumberAtTime } from "@/animation/values"; +import { useEditor } from "@/editor/use-editor"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; +import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { VolumeHighIcon } from "@hugeicons/core-free-icons"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; + +const VOLUME_STEP = 0.1; +const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); + +export function AudioTab({ + element, + trackId, +}: { + element: AudioElement | VideoElement; + trackId: string; +}) { + const editor = useEditor(); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedVolume = resolveNumberAtTime({ + baseValue: element.volume ?? DEFAULTS.element.volume, + animations: element.animations, + propertyPath: "volume", + localTime, + }); + const volume = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "volume", + localTime, + isPlayheadWithinElementRange, + displayValue: formatNumberForDisplay({ + value: resolvedVolume, + fractionDigits: VOLUME_FRACTION_DIGITS, + }), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) { + return null; + } + + return clamp({ + value: snapToStep({ value: parsed, step: VOLUME_STEP }), + min: VOLUME_DB_MIN, + max: VOLUME_DB_MAX, + }); + }, + valueAtPlayhead: resolvedVolume, + step: VOLUME_STEP, + buildBaseUpdates: ({ value }) => ({ + volume: value, + }), + }); + const isDefault = + volume.hasAnimatedKeyframes && isPlayheadWithinElementRange + ? isNearlyEqual({ + leftValue: resolvedVolume, + rightValue: DEFAULTS.element.volume, + }) + : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; + const isSeparated = + element.type === "video" && isSourceAudioSeparated({ element }); + + return ( + <> + {isSeparated && ( +
+

Audio has been separated.

+ +
+ )} +
+ + Audio + + + + + } + > + } + value={volume.displayValue} + onFocus={volume.onFocus} + onChange={volume.onChange} + onBlur={volume.onBlur} + dragSensitivity="slow" + scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} + onScrub={volume.scrubTo} + onScrubEnd={volume.commitScrub} + onReset={() => + volume.commitValue({ + value: DEFAULTS.element.volume, + }) + } + isDefault={isDefault} + suffix="dB" + /> + + + +
+ + ); +} diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts index 5260c046..d2b7d82f 100644 --- a/apps/web/src/timeline/controllers/playhead-controller.ts +++ b/apps/web/src/timeline/controllers/playhead-controller.ts @@ -8,7 +8,7 @@ import { } from "@/timeline/snapping"; import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import { getCenteredLineLeft, timelineTimeToPixels, diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts index 8677dd15..cdf502a1 100644 --- a/apps/web/src/timeline/controllers/resize-controller.ts +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -16,7 +16,7 @@ import { } from "@/timeline/snapping"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import { isRetimableElement, type SceneTracks, diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index 32c0a2f1..7f431e6e 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -7,7 +7,7 @@ import { } from "@/timeline/snapping"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { MoveGroup } from "./types"; export function snapGroupEdges({