From 6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 25 Apr 2026 03:56:22 +0200 Subject: refactor: centralize editor interaction state Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor --- .../controllers/transform-handle-controller.ts | 753 +++++++++++++++++++++ 1 file changed, 753 insertions(+) create mode 100644 apps/web/src/preview/controllers/transform-handle-controller.ts (limited to 'apps/web/src/preview/controllers/transform-handle-controller.ts') diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts new file mode 100644 index 00000000..e9d251e3 --- /dev/null +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -0,0 +1,753 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import type { MediaAsset } from "@/media/types"; +import { + getVisibleElementsWithBounds, + type Corner, + type Edge, + type ElementBounds, + type ElementWithBounds, +} from "@/preview/element-bounds"; +import { + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/preview/preview-snap"; +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 type { + ElementRef, + SceneTracks, + TimelineElement, + VisualElement, +} from "@/timeline"; + +type Point = { readonly x: number; readonly y: number }; +type CanvasSize = { readonly width: number; readonly height: number }; +type HandleType = Corner | Edge | "rotation"; + +interface CapturedPointerState { + readonly pointerId: number; + readonly captureTarget: HTMLElement; +} + +interface CornerScaleSession extends CapturedPointerState { + readonly kind: "corner-scale"; + readonly corner: Corner; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialDistance: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface EdgeScaleSession extends CapturedPointerState { + readonly kind: "edge-scale"; + readonly edge: Edge; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly rotationRad: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface RotationSession extends CapturedPointerState { + readonly kind: "rotation"; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialAngle: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; +} + +type TransformSession = + | { readonly kind: "idle" } + | CornerScaleSession + | EdgeScaleSession + | RotationSession; + +const IDLE_SESSION: TransformSession = { kind: "idle" }; + +interface VisualSelectionContext { + readonly trackId: string; + readonly elementId: string; + readonly element: VisualElement; + readonly bounds: ElementBounds; + readonly resolvedTransform: Transform; +} + +export interface PreviewViewportAdapter { + screenToCanvas: ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => Point | null; + screenPixelsToLogicalThreshold: ({ + screenPixels, + }: { + screenPixels: number; + }) => Point; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getSelectedElements: () => readonly ElementRef[]; + getTracks: () => SceneTracks; + getCurrentTime: () => number; + getMediaAssets: () => MediaAsset[]; + getCanvasSize: () => CanvasSize; +} + +export interface TimelinePreviewUpdate { + readonly trackId: string; + readonly elementId: string; + readonly updates: Partial; +} + +export interface TimelineOps { + previewElements: (updates: readonly TimelinePreviewUpdate[]) => void; + commitPreview: () => void; + discardPreview: () => void; +} + +export interface PreviewOptions { + onSnapLinesChange?: (lines: SnapLine[]) => void; +} + +export interface TransformHandleDeps { + viewport: PreviewViewportAdapter; + input: InputAdapter; + scene: SceneReader; + timeline: TimelineOps; + preview: PreviewOptions; +} + +export interface TransformHandleDepsRef { + readonly current: TransformHandleDeps; +} + +function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; + } + return scale; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): 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 = + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; + const localY = + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +function buildSelectedWithBounds({ + selectedElements, + elementsWithBounds, +}: { + selectedElements: readonly ElementRef[]; + elementsWithBounds: readonly ElementWithBounds[]; +}): ElementWithBounds | null { + if (selectedElements.length !== 1) return null; + + return ( + elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null + ); +} + +function buildCornerScaleAnimationReset({ + animations, +}: { + animations: ElementAnimations | undefined; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleX", + }) || + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleY", + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations: setChannel({ + animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", + channel: undefined, + }) + : animations, + }; +} + +function buildEdgeScaleAnimationReset({ + animations, + edge, +}: { + animations: ElementAnimations | undefined; + edge: Edge; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + + const shouldClearScaleAnimation = hasKeyframesForPath({ + animations, + propertyPath, + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations, + propertyPath, + channel: undefined, + }) + : animations, + }; +} + +export class TransformHandleController { + private readonly depsRef: TransformHandleDepsRef; + private readonly subscribers = new Set<() => void>(); + + private session: TransformSession = IDLE_SESSION; + + constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) { + this.depsRef = depsRef; + + this.onCornerPointerDown = this.onCornerPointerDown.bind(this); + this.onEdgePointerDown = this.onEdgePointerDown.bind(this); + this.onRotationPointerDown = this.onRotationPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + } + + private get deps(): TransformHandleDeps { + return this.depsRef.current; + } + + get selectedWithBounds(): ElementWithBounds | null { + return buildSelectedWithBounds({ + selectedElements: this.deps.scene.getSelectedElements(), + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + } + + get activeHandle(): HandleType | null { + switch (this.session.kind) { + case "corner-scale": + return this.session.corner; + case "edge-scale": + return this.session.edge; + case "rotation": + return "rotation"; + default: + return null; + } + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.session.kind !== "idle") { + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + } + + this.subscribers.clear(); + } + + cancel(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + onCornerPointerDown({ + event, + corner, + }: { + event: ReactPointerEvent; + corner: Corner; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildCornerScaleAnimationReset({ + animations: context.element.animations, + }); + + this.session = { + kind: "corner-scale", + corner, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialDistance: getCornerDistance({ + bounds: context.bounds, + corner, + }), + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onRotationPointerDown({ event }: { event: ReactPointerEvent }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + const deltaX = position.x - context.bounds.cx; + const deltaY = position.y - context.bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + + this.session = { + kind: "rotation", + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialAngle, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onEdgePointerDown({ + event, + edge, + }: { + event: ReactPointerEvent; + edge: Edge; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildEdgeScaleAnimationReset({ + animations: context.element.animations, + edge, + }); + + this.session = { + kind: "edge-scale", + edge, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + rotationRad: (context.bounds.rotation * Math.PI) / 180, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onPointerMove({ event }: { event: ReactPointerEvent }): void { + if (this.session.kind === "idle") return; + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + switch (this.session.kind) { + case "corner-scale": + this.previewCornerScale({ + session: this.session, + position, + }); + return; + case "edge-scale": + this.previewEdgeScale({ + session: this.session, + position, + }); + return; + case "rotation": + this.previewRotation({ + session: this.session, + position, + }); + return; + default: + return; + } + } + + onPointerUp(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.commitPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private clearSnapLines(): void { + this.deps.preview.onSnapLinesChange?.([]); + } + + private capturePointer({ + target, + pointerId, + }: { + target: HTMLElement; + pointerId: number; + }): HTMLElement { + target.setPointerCapture(pointerId); + return target; + } + + private releaseCapturedPointer(pointerState: CapturedPointerState): void { + if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) { + return; + } + + pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + } + + private getVisibleElementsWithBounds(): ElementWithBounds[] { + return getVisibleElementsWithBounds({ + tracks: this.deps.scene.getTracks(), + currentTime: this.deps.scene.getCurrentTime(), + canvasSize: this.deps.scene.getCanvasSize(), + mediaAssets: this.deps.scene.getMediaAssets(), + }); + } + + private getSelectedVisualContext(): VisualSelectionContext | null { + const selectedWithBounds = this.selectedWithBounds; + if (!selectedWithBounds) return null; + if (!isVisualElement(selectedWithBounds.element)) return null; + + const localTime = getElementLocalTime({ + timelineTime: this.deps.scene.getCurrentTime(), + elementStartTime: selectedWithBounds.element.startTime, + elementDuration: selectedWithBounds.element.duration, + }); + + return { + trackId: selectedWithBounds.trackId, + elementId: selectedWithBounds.elementId, + element: selectedWithBounds.element, + bounds: selectedWithBounds.bounds, + resolvedTransform: resolveTransformAtTime({ + baseTransform: selectedWithBounds.element.transform, + animations: selectedWithBounds.element.animations, + localTime, + }), + }; + } + + private previewCornerScale({ + session, + position, + }: { + session: CornerScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const scaleFactor = currentDistance / session.initialDistance; + + // Use actual element dimensions (base * current scale) so snapping is + // computed from the rendered geometry when scaleX != scaleY. + const effectiveWidth = session.baseWidth * session.initialTransform.scaleX; + const effectiveHeight = + session.baseHeight * session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedScale, activeLines } = this.deps.input.isShiftHeld() + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: session.initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + }); + + this.deps.preview.onSnapLinesChange?.(activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: clampScaleNonZero( + session.initialTransform.scaleX * snappedScale, + ), + scaleY: clampScaleNonZero( + session.initialTransform.scaleY * snappedScale, + ), + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewEdgeScale({ + session, + position, + }: { + session: EdgeScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const xProjection = + deltaX * Math.cos(session.rotationRad) + + deltaY * Math.sin(session.rotationRad); + const yProjection = + -deltaX * Math.sin(session.rotationRad) + + deltaY * Math.cos(session.rotationRad); + const projection = + session.edge === "right" + ? xProjection + : session.edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + session.edge === "right" || session.edge === "left" + ? session.baseWidth / 2 + : session.baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + session.edge === "right" || session.edge === "left" + ? proposedScale + : session.initialTransform.scaleX; + const proposedScaleY = + session.edge === "bottom" + ? proposedScale + : session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld() + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position: session.initialTransform.position, + baseWidth: session.baseWidth, + baseHeight: session.baseHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + preferredEdges: getPreferredEdge({ edge: session.edge }), + }); + + const relevantSnap = + session.edge === "right" || session.edge === "left" ? xSnap : ySnap; + this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: + session.edge === "right" || session.edge === "left" + ? xSnap.snappedScale + : session.initialTransform.scaleX, + scaleY: + session.edge === "bottom" + ? ySnap.snappedScale + : session.initialTransform.scaleY, + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewRotation({ + session, + position, + }: { + session: RotationSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + let deltaAngle = currentAngle - session.initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + const newRotate = session.initialTransform.rotate + deltaAngle; + const { snappedRotation } = this.deps.input.isShiftHeld() + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + rotate: snappedRotation, + }, + }, + }, + ]); + } +} -- cgit v1.3.1 From 0b7597b31fa956c7f3d4838923b545ba5e341422 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 04:04:30 +0200 Subject: refactor: split animation helpers by domain --- .../animation/__tests__/animated-params.test.ts | 241 ++++++++ apps/web/src/animation/animated-params.ts | 83 +++ apps/web/src/animation/effect-param-channel.ts | 16 +- apps/web/src/animation/graphic-param-channel.ts | 32 +- apps/web/src/animation/index.ts | 30 +- apps/web/src/animation/interpolation.ts | 2 +- apps/web/src/animation/keyframe-query.ts | 2 +- apps/web/src/animation/keyframes.ts | 48 +- apps/web/src/animation/path.ts | 22 + apps/web/src/animation/property-registry.ts | 390 ------------- apps/web/src/animation/resolve.ts | 105 ---- apps/web/src/animation/target-resolver.ts | 285 ---------- apps/web/src/animation/timeline-snap-points.ts | 35 -- apps/web/src/animation/types.ts | 10 +- apps/web/src/animation/values.ts | 61 ++ .../commands/timeline/clipboard/paste-keyframes.ts | 2 +- .../timeline/element/keyframes/remove-keyframe.ts | 2 +- .../timeline/element/keyframes/retime-keyframe.ts | 3 +- .../keyframes/update-scalar-keyframe-curve.ts | 2 +- .../keyframes/upsert-effect-param-keyframe.ts | 2 +- .../timeline/element/keyframes/upsert-keyframe.ts | 3 +- .../hooks/use-keyframed-color-property.ts | 190 +++---- .../hooks/use-keyframed-number-property.ts | 348 ++++++------ .../hooks/use-keyframed-param-property.ts | 14 +- apps/web/src/core/managers/timeline-manager.ts | 2 +- apps/web/src/graphics/components/graphic-tab.tsx | 479 ++++++++-------- apps/web/src/graphics/index.ts | 24 + .../src/preview/components/text-edit-overlay.tsx | 2 +- .../controllers/transform-handle-controller.ts | 2 +- apps/web/src/preview/element-bounds.ts | 616 ++++++++++----------- apps/web/src/rendering/NOTES.md | 27 + apps/web/src/rendering/animation-values.ts | 49 ++ apps/web/src/rendering/components/blending-tab.tsx | 2 +- .../web/src/rendering/components/transform-tab.tsx | 2 +- apps/web/src/services/renderer/resolve.ts | 20 +- apps/web/src/text/components/text-tab.tsx | 5 +- apps/web/src/text/measure-element.ts | 2 +- apps/web/src/timeline/animation-properties.ts | 425 ++++++++++++++ apps/web/src/timeline/animation-snap-points.ts | 35 ++ apps/web/src/timeline/animation-targets.ts | 214 +++++++ apps/web/src/timeline/audio-state.ts | 2 +- .../timeline/bookmarks/hooks/use-bookmark-drag.ts | 2 +- apps/web/src/timeline/components/audio-tab.tsx | 302 +++++----- .../timeline/controllers/playhead-controller.ts | 2 +- .../src/timeline/controllers/resize-controller.ts | 2 +- apps/web/src/timeline/group-move/snap.ts | 2 +- 46 files changed, 2226 insertions(+), 1920 deletions(-) create mode 100644 apps/web/src/animation/__tests__/animated-params.test.ts create mode 100644 apps/web/src/animation/animated-params.ts create mode 100644 apps/web/src/animation/path.ts delete mode 100644 apps/web/src/animation/property-registry.ts delete mode 100644 apps/web/src/animation/target-resolver.ts delete mode 100644 apps/web/src/animation/timeline-snap-points.ts create mode 100644 apps/web/src/animation/values.ts create mode 100644 apps/web/src/rendering/NOTES.md create mode 100644 apps/web/src/rendering/animation-values.ts create mode 100644 apps/web/src/timeline/animation-properties.ts create mode 100644 apps/web/src/timeline/animation-snap-points.ts create mode 100644 apps/web/src/timeline/animation-targets.ts (limited to 'apps/web/src/preview/controllers/transform-handle-controller.ts') 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/property-registry.ts b/apps/web/src/animation/property-registry.ts deleted file mode 100644 index aacb9972..00000000 --- a/apps/web/src/animation/property-registry.ts +++ /dev/null @@ -1,390 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, -} from "@/animation/types"; -import { parseColorToLinearRgba } from "./binding-values"; -import type { TimelineElement } from "@/timeline"; -import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/text/background"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { DEFAULTS } from "@/timeline/defaults"; -import { snapToStep } from "@/utils/math"; - -export interface NumericSpec { - min?: number; - max?: number; - step?: number; -} - -export interface AnimationPropertyDefinition { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial>; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceNumberValue({ - value, - numericRange, -}: { - value: AnimationValue; - numericRange?: NumericSpec; -}): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ value, numericRange }); -} - -function coerceColorValue({ - value, -}: { - value: AnimationValue; -}): string | null { - return typeof value === "string" && parseColorToLinearRgba({ color: value }) - ? value - : null; -} - -function createNumberPropertyDefinition({ - numericRange, - supportsElement, - getValue, - setValue, -}: { - numericRange?: NumericSpec; - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; -}): AnimationPropertyDefinition { - return { - kind: "number", - defaultInterpolation: "linear", - numericRanges: numericRange ? { value: numericRange } : undefined, - supportsElement, - getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, - }; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.positionX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, x: value as number }, - }, - } - : element, - }), - "transform.positionY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value as number }, - }, - } - : element, - }), - "transform.scaleX": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }), - "transform.scaleY": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }), - "transform.rotate": createNumberPropertyDefinition({ - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }), - opacity: createNumberPropertyDefinition({ - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }), - volume: createNumberPropertyDefinition({ - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }), - color: { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }), - "background.paddingY": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }), - "background.offsetX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }), - "background.offsetY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }), - "background.cornerRadius": createNumberPropertyDefinition({ - numericRange: { - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - step: 1, - }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }), -}; - -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.coerceValue({ value }); -} diff --git a/apps/web/src/animation/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/target-resolver.ts b/apps/web/src/animation/target-resolver.ts deleted file mode 100644 index 32e7917c..00000000 --- a/apps/web/src/animation/target-resolver.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPath, - AnimationValue, -} from "@/animation/types"; -import { - parseEffectParamPath, - isEffectParamPath, -} from "@/animation/effect-param-channel"; -import { - isGraphicParamPath, - parseGraphicParamPath, -} from "@/animation/graphic-param-channel"; -import type { ParamDefinition } from "@/params"; -import { effectsRegistry, registerDefaultEffects } from "@/effects"; -import { getGraphicDefinition } from "@/graphics"; -import type { TimelineElement } from "@/timeline"; -import { isVisualElement } from "@/timeline/element-utils"; -import { snapToStep } from "@/utils/math"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - isAnimationPropertyPath, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; -import { parseColorToLinearRgba } from "./binding-values"; - -export interface AnimationPathDescriptor { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial>; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - getBaseValue: () => AnimationValue | null; - 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({ - 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; -} - -function buildGraphicParamDescriptor({ - element, - paramKey, -}: { - element: TimelineElement; - paramKey: string; -}): AnimationPathDescriptor | null { - if (element.type !== "graphic") { - return null; - } - - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), - getBaseValue: () => element.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; - }, - }; -} - -function buildEffectParamDescriptor({ - element, - effectId, - paramKey, -}: { - element: TimelineElement; - effectId: string; - paramKey: string; -}): AnimationPathDescriptor | null { - if (!isVisualElement(element)) { - return null; - } - - const effect = element.effects?.find((candidate) => candidate.id === effectId); - if (!effect) { - return null; - } - - registerDefaultEffects(); - const definition = effectsRegistry.get(effect.type); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), - getBaseValue: () => effect.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; -} - -export function isAnimationPath( - propertyPath: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - -export function resolveAnimationTarget({ - element, - path, -}: { - element: TimelineElement; - path: AnimationPath; -}): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRanges: propertyDefinition.numericRanges, - coerceValue: ({ value }) => - coerceAnimationValueForProperty({ - propertyPath: path, - value, - }), - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: ({ value }) => { - const coercedValue = propertyDefinition.coerceValue({ value }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; - } - - const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); - if (graphicParamTarget) { - return buildGraphicParamDescriptor({ - element, - paramKey: graphicParamTarget.paramKey, - }); - } - - const effectParamTarget = parseEffectParamPath({ propertyPath: path }); - if (effectParamTarget) { - return buildEffectParamDescriptor({ - element, - effectId: effectParamTarget.effectId, - paramKey: effectParamTarget.paramKey, - }); - } - - return null; -} diff --git a/apps/web/src/animation/timeline-snap-points.ts b/apps/web/src/animation/timeline-snap-points.ts deleted file mode 100644 index f7bf549a..00000000 --- a/apps/web/src/animation/timeline-snap-points.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getElementKeyframes } from "@/animation"; -import type { SceneTracks } from "@/timeline"; -import type { SnapPoint } from "@/timeline/snapping"; - -export function getAnimationKeyframeSnapPointsForTimeline({ - tracks, - excludeElementIds, -}: { - tracks: SceneTracks; - excludeElementIds?: Set; -}): SnapPoint[] { - const snapPoints: SnapPoint[] = []; - const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - - for (const track of orderedTracks) { - for (const element of track.elements) { - if (excludeElementIds?.has(element.id)) { - continue; - } - - for (const keyframe of getElementKeyframes({ - animations: element.animations, - })) { - snapPoints.push({ - time: element.startTime + keyframe.time, - type: "keyframe", - elementId: element.id, - trackId: track.id, - }); - } - } - } - - return snapPoints; -} 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/timeline/animation-properties.ts b/apps/web/src/timeline/animation-properties.ts new file mode 100644 index 00000000..31479a58 --- /dev/null +++ b/apps/web/src/timeline/animation-properties.ts @@ -0,0 +1,425 @@ +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, + ElementAnimations, + NumericSpec, +} from "@/animation/types"; +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, +} from "@/text/background"; +import { + canElementHaveAudio, + isVisualElement, +} from "@/timeline/element-utils"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { DEFAULTS } from "@/timeline/defaults"; + +export interface AnimationPropertyDefinition { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + supportsElement: ({ element }: { element: TimelineElement }) => boolean; + getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: AnimationValue; + }) => TimelineElement; +} + +function applyNumericSpec({ + value, + numericRange, +}: { + value: number; + numericRange: NumericSpec | undefined; +}): number { + if (!numericRange) { + return value; + } + + const steppedValue = + numericRange.step != null + ? snapToStep({ value, step: numericRange.step }) + : value; + const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; + const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); +} + +function coerceNumberValue({ + value, + numericRange, +}: { + value: AnimationValue; + numericRange?: NumericSpec; +}): number | null { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + return applyNumericSpec({ value, numericRange }); +} + +function coerceColorValue({ + value, +}: { + value: AnimationValue; +}): string | null { + return typeof value === "string" && parseColorToLinearRgba({ color: value }) + ? value + : null; +} + +function createNumberPropertyDefinition({ + numericRange, + supportsElement, + getValue, + setValue, +}: { + numericRange?: NumericSpec; + supportsElement: AnimationPropertyDefinition["supportsElement"]; + getValue: AnimationPropertyDefinition["getValue"]; + setValue: AnimationPropertyDefinition["setValue"]; +}): AnimationPropertyDefinition { + return { + kind: "number", + defaultInterpolation: "linear", + numericRanges: numericRange ? { value: numericRange } : undefined, + supportsElement, + getValue, + coerceValue: ({ value }) => + coerceNumberValue({ + value, + numericRange, + }), + setValue, + }; +} + +const ANIMATION_PROPERTY_REGISTRY: Record< + AnimationPropertyPath, + AnimationPropertyDefinition +> = { + "transform.positionX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.x : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, x: value as number }, + }, + } + : element, + }), + "transform.positionY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.y : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, y: value as number }, + }, + } + : element, + }), + "transform.scaleX": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleX : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleX: value as number }, + } + : element, + }), + "transform.scaleY": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleY : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleY: value as number }, + } + : element, + }), + "transform.rotate": createNumberPropertyDefinition({ + numericRange: { min: -360, max: 360, step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.rotate : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, rotate: value as number }, + } + : element, + }), + opacity: createNumberPropertyDefinition({ + numericRange: { min: 0, max: 1, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.opacity : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { ...element, opacity: value as number } + : element, + }), + volume: createNumberPropertyDefinition({ + numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, + supportsElement: ({ element }) => canElementHaveAudio(element), + getValue: ({ element }) => + canElementHaveAudio(element) ? element.volume ?? 0 : null, + setValue: ({ element, value }) => + canElementHaveAudio(element) + ? { ...element, volume: value as number } + : element, + }), + color: { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => (element.type === "text" ? element.color : null), + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { ...element, color: value as string } + : element, + }, + "background.color": { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" ? element.background.color : null, + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, color: value as string }, + } + : element, + }, + "background.paddingX": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingX: value as number }, + } + : element, + }), + "background.paddingY": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingY: value as number }, + } + : element, + }), + "background.offsetX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetX: value as number }, + } + : element, + }), + "background.offsetY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetY: value as number }, + } + : element, + }), + "background.cornerRadius": createNumberPropertyDefinition({ + numericRange: { + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + step: 1, + }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, cornerRadius: value as number }, + } + : element, + }), +}; + +export function getAnimationPropertyDefinition({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationPropertyDefinition { + return ANIMATION_PROPERTY_REGISTRY[propertyPath]; +} + +export function supportsAnimationProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): boolean { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.supportsElement({ element }); +} + +export function getElementBaseValueForProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): AnimationValue | null { + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return null; + } + + return definition.getValue({ element }); +} + +export function withElementBaseValueForProperty({ + element, + propertyPath, + value, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): TimelineElement { + const definition = getAnimationPropertyDefinition({ propertyPath }); + const coercedValue = definition.coerceValue({ value }); + if (coercedValue === null || !definition.supportsElement({ element })) { + return element; + } + + return definition.setValue({ element, value: coercedValue }); +} + +export function getDefaultInterpolationForProperty({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationInterpolation { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.defaultInterpolation; +} + +export function coerceAnimationValueForProperty({ + propertyPath, + value, +}: { + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): AnimationValue | null { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.coerceValue({ value }); +} + +export function upsertElementKeyframe({ + animations, + propertyPath, + time, + value, + interpolation, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + time: 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/timeline/animation-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts new file mode 100644 index 00000000..f7bf549a --- /dev/null +++ b/apps/web/src/timeline/animation-snap-points.ts @@ -0,0 +1,35 @@ +import { getElementKeyframes } from "@/animation"; +import type { SceneTracks } from "@/timeline"; +import type { SnapPoint } from "@/timeline/snapping"; + +export function getAnimationKeyframeSnapPointsForTimeline({ + tracks, + excludeElementIds, +}: { + tracks: SceneTracks; + excludeElementIds?: Set; +}): SnapPoint[] { + const snapPoints: SnapPoint[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + + for (const track of orderedTracks) { + for (const element of track.elements) { + if (excludeElementIds?.has(element.id)) { + continue; + } + + for (const keyframe of getElementKeyframes({ + animations: element.animations, + })) { + snapPoints.push({ + time: element.startTime + keyframe.time, + type: "keyframe", + elementId: element.id, + trackId: track.id, + }); + } + } + } + + return snapPoints; +} diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts new file mode 100644 index 00000000..f9d53fcc --- /dev/null +++ b/apps/web/src/timeline/animation-targets.ts @@ -0,0 +1,214 @@ +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPath, + AnimationValue, + NumericSpec, +} from "@/animation/types"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamNumericRange, + getAnimationParamValueKind, +} from "@/animation/animated-params"; +import { + parseEffectParamPath, +} from "@/animation/effect-param-channel"; +import { + parseGraphicParamPath, +} from "@/animation/graphic-param-channel"; +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 { isAnimationPropertyPath } from "@/animation/path"; +import { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getElementBaseValueForProperty, + withElementBaseValueForProperty, +} from "./animation-properties"; + +export interface AnimationPathDescriptor { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + getBaseValue: () => AnimationValue | null; + setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; +} + +// Number/discrete bindings expose a single component named "value" +// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry +// numeric ranges yet — revisit when one does. +function paramNumericRanges({ + param, +}: { + param: ParamDefinition; +}): Partial> | undefined { + const range = getAnimationParamNumericRange({ param }); + return range ? { value: range } : undefined; +} + +function buildGraphicParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + if (element.type !== "graphic") { + return null; + } + + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + 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 = coerceAnimationParamValue({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + params: { + ...element.params, + [param.key]: coercedValue, + }, + }; + }, + }; +} + +function buildEffectParamDescriptor({ + element, + effectId, + paramKey, +}: { + element: TimelineElement; + effectId: string; + paramKey: string; +}): AnimationPathDescriptor | null { + if (!isVisualElement(element)) { + return null; + } + + const effect = element.effects?.find((candidate) => candidate.id === effectId); + if (!effect) { + return null; + } + + registerDefaultEffects(); + const definition = effectsRegistry.get(effect.type); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + 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 = coerceAnimationParamValue({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params: { + ...candidate.params, + [param.key]: coercedValue, + }, + }, + ) ?? element.effects, + }; + }, + }; +} + +export function resolveAnimationTarget({ + element, + path, +}: { + element: TimelineElement; + path: AnimationPath; +}): AnimationPathDescriptor | null { + if (isAnimationPropertyPath(path)) { + const propertyDefinition = getAnimationPropertyDefinition({ + propertyPath: path, + }); + if (!propertyDefinition.supportsElement({ element })) { + return null; + } + + return { + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + numericRanges: propertyDefinition.numericRanges, + coerceValue: ({ value }) => + coerceAnimationValueForProperty({ + propertyPath: path, + value, + }), + getBaseValue: () => + getElementBaseValueForProperty({ + element, + propertyPath: path, + }), + setBaseValue: ({ value }) => { + const coercedValue = propertyDefinition.coerceValue({ value }); + if (coercedValue === null) { + return element; + } + + return withElementBaseValueForProperty({ + element, + propertyPath: path, + value: coercedValue, + }); + }, + }; + } + + const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); + if (graphicParamTarget) { + return buildGraphicParamDescriptor({ + element, + paramKey: graphicParamTarget.paramKey, + }); + } + + const effectParamTarget = parseEffectParamPath({ propertyPath: path }); + if (effectParamTarget) { + return buildEffectParamDescriptor({ + element, + effectId: effectParamTarget.effectId, + paramKey: effectParamTarget.paramKey, + }); + } + + return null; +} 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({ -- cgit v1.3.1