summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components/graph-editor/session.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/timeline/components/graph-editor/session.ts')
-rw-r--r--apps/web/src/timeline/components/graph-editor/session.ts704
1 files changed, 704 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts
new file mode 100644
index 00000000..d7c6b001
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/session.ts
@@ -0,0 +1,704 @@
+import {
+ getCurveHandlesForNormalizedCubicBezier,
+ getEditableScalarChannels,
+ getEasingModeForKind,
+ getNormalizedCubicBezierForScalarSegment,
+ getScalarKeyframeContext,
+ updateScalarKeyframeCurve,
+} from "@/animation";
+import type {
+ AnimationPath,
+ ElementAnimations,
+ NormalizedCubicBezier,
+ ScalarCurveKeyframePatch,
+ ScalarGraphKeyframeContext,
+ SelectedKeyframeRef,
+} from "@/animation/types";
+import type { SceneTracks, TimelineElement } from "@/timeline";
+
+const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
+const FLAT_VALUE_EPSILON = 1e-6;
+const LINEAR_CURVE_EPSILON = 1e-6;
+
+export type GraphEditorUnavailableReason =
+ | "no-keyframe-selected"
+ | "multiple-keyframes-selected"
+ | "selected-keyframes-span-multiple-elements"
+ | "selected-keyframes-are-not-adjacent"
+ | "selected-properties-have-no-shared-component"
+ | "selected-element-missing"
+ | "selected-element-has-no-animations"
+ | "selected-keyframe-has-no-scalar-channel"
+ | "selected-keyframe-missing-on-channel"
+ | "selected-keyframe-has-no-next-segment"
+ | "selected-segment-is-hold"
+ | "selected-segment-is-flat";
+
+export interface GraphEditorComponentOption {
+ key: string;
+ label: string;
+}
+
+interface GraphEditorPropertyOption {
+ key: string;
+ label: string;
+ context: ScalarGraphKeyframeContext;
+ allContexts: ScalarGraphKeyframeContext[];
+}
+
+export interface GraphEditorResolvedSegment {
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframeId: string;
+ context: ScalarGraphKeyframeContext;
+ allContexts: ScalarGraphKeyframeContext[];
+ cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
+}
+
+interface GraphEditorBaseSelectionState {
+ componentOptions: GraphEditorComponentOption[];
+ activeComponentKey: string | null;
+ message: string;
+}
+
+export interface GraphEditorUnavailableState
+ extends GraphEditorBaseSelectionState {
+ status: "unavailable";
+ reason: GraphEditorUnavailableReason;
+}
+
+export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
+ status: "ready";
+ trackId: string;
+ elementId: string;
+ element: TimelineElement;
+ segments: GraphEditorResolvedSegment[];
+ cubicBezier: NormalizedCubicBezier;
+}
+
+export type GraphEditorSelectionState =
+ | GraphEditorUnavailableState
+ | GraphEditorReadyState;
+
+export interface GraphEditorCurvePatch {
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+}
+
+function createUnavailableState({
+ reason,
+ message,
+ componentOptions = [],
+ activeComponentKey = null,
+}: {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ componentOptions?: GraphEditorComponentOption[];
+ activeComponentKey?: string | null;
+}): GraphEditorUnavailableState {
+ return {
+ status: "unavailable",
+ reason,
+ message,
+ componentOptions,
+ activeComponentKey,
+ };
+}
+
+function findElementByKeyframe({
+ tracks,
+ keyframe,
+}: {
+ tracks: SceneTracks;
+ keyframe: SelectedKeyframeRef;
+}): { element: TimelineElement; trackId: string; elementId: string } | null {
+ for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
+ if (track.id !== keyframe.trackId) {
+ continue;
+ }
+
+ const element = track.elements.find(
+ (trackElement) => trackElement.id === keyframe.elementId,
+ );
+ if (!element) {
+ return null;
+ }
+
+ return {
+ element,
+ trackId: track.id,
+ elementId: element.id,
+ };
+ }
+
+ return null;
+}
+
+function findKeyframeTime({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): number | null {
+ const binding = animations.bindings[propertyPath];
+ if (!binding) return null;
+
+ for (const component of binding.components) {
+ const channel = animations.channels[component.channelId];
+ if (channel?.kind !== "scalar") continue;
+ const key = channel.keys.find((k) => k.id === keyframeId);
+ if (key !== undefined) return key.time;
+ }
+
+ return null;
+}
+
+function groupSelectedKeyframesByProperty({
+ selectedKeyframes,
+}: {
+ selectedKeyframes: SelectedKeyframeRef[];
+}) {
+ const groups = new Map<
+ string,
+ {
+ trackId: string;
+ elementId: string;
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframes: SelectedKeyframeRef[];
+ }
+ >();
+
+ for (const keyframe of selectedKeyframes) {
+ const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`;
+ const existingGroup = groups.get(groupKey);
+ if (existingGroup) {
+ existingGroup.keyframes.push(keyframe);
+ continue;
+ }
+
+ groups.set(groupKey, {
+ trackId: keyframe.trackId,
+ elementId: keyframe.elementId,
+ propertyPath: keyframe.propertyPath,
+ keyframes: [keyframe],
+ });
+ }
+
+ return [...groups.values()];
+}
+
+function getComponentLabel({ componentKey }: { componentKey: string }): string {
+ switch (componentKey) {
+ case "value":
+ return "Value";
+ default:
+ return componentKey.toUpperCase();
+ }
+}
+
+/**
+ * Returns the absolute value span of the nearest non-flat adjacent segment,
+ * used as the Y-axis scale when editing a flat segment in the graph editor.
+ * Falls back to 1.0 if all surrounding segments are also flat.
+ */
+function getReferenceSpanValue({
+ context,
+}: {
+ context: ScalarGraphKeyframeContext;
+}): number {
+ const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time);
+ const leftIndex = sorted.findIndex((k) => k.id === context.keyframe.id);
+ const rightIndex = context.nextKey
+ ? sorted.findIndex((k) => k.id === context.nextKey?.id)
+ : -1;
+
+ for (let i = leftIndex - 1; i >= 0; i--) {
+ const span = Math.abs(sorted[i + 1].value - sorted[i].value);
+ if (span > FLAT_VALUE_EPSILON) return span;
+ }
+
+ if (rightIndex !== -1) {
+ for (let i = rightIndex; i < sorted.length - 1; i++) {
+ const span = Math.abs(sorted[i + 1].value - sorted[i].value);
+ if (span > FLAT_VALUE_EPSILON) return span;
+ }
+ }
+
+ return 1.0;
+}
+
+interface GraphEditorPropertySelection {
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframeId: string;
+ secondaryKeyframeId: string | null;
+ options: GraphEditorPropertyOption[];
+}
+
+function resolvePropertySelection({
+ element,
+ propertyKeyframes,
+}: {
+ element: TimelineElement;
+ propertyKeyframes: ReturnType<
+ typeof groupSelectedKeyframesByProperty
+ >[number];
+}):
+ | GraphEditorPropertySelection
+ | {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ } {
+ if (propertyKeyframes.keyframes.length > 2) {
+ return {
+ reason: "multiple-keyframes-selected",
+ message: "Select at most two adjacent keyframes per property.",
+ };
+ }
+
+ if (!element.animations) {
+ return {
+ reason: "selected-element-has-no-animations",
+ message: "The selected keyframe has no editable graph.",
+ };
+ }
+
+ const scalarResult = getEditableScalarChannels({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ });
+ if (!scalarResult || scalarResult.channels.length === 0) {
+ return {
+ reason: "selected-keyframe-has-no-scalar-channel",
+ message: "The selected keyframe has no editable graph channel.",
+ };
+ }
+
+ const primaryKeyframe = propertyKeyframes.keyframes[0];
+ let resolvedKeyframeId = primaryKeyframe.keyframeId;
+ let secondaryKeyframeId =
+ propertyKeyframes.keyframes.length === 2
+ ? propertyKeyframes.keyframes[1].keyframeId
+ : null;
+
+ if (secondaryKeyframeId !== null) {
+ const time1 = findKeyframeTime({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: primaryKeyframe.keyframeId,
+ });
+ const time2 = findKeyframeTime({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: secondaryKeyframeId,
+ });
+ if (time2 !== null && (time1 === null || time2 < time1)) {
+ resolvedKeyframeId = secondaryKeyframeId;
+ secondaryKeyframeId = primaryKeyframe.keyframeId;
+ }
+ }
+
+ const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
+ const easingMode = getEasingModeForKind(resolvedBinding.kind);
+ const contexts = scalarChannels.flatMap((channel) => {
+ const context = getScalarKeyframeContext({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ componentKey: channel.componentKey,
+ keyframeId: resolvedKeyframeId,
+ });
+ if (!context) {
+ return [];
+ }
+
+ return [
+ {
+ context,
+ option: {
+ key: channel.componentKey,
+ label: getComponentLabel({ componentKey: channel.componentKey }),
+ },
+ },
+ ];
+ });
+
+ if (contexts.length === 0) {
+ return {
+ reason: "selected-keyframe-missing-on-channel",
+ message: "The selected keyframe is not editable as a graph segment.",
+ };
+ }
+
+ // For shared-easing bindings (e.g. color), all components always use the same
+ // curve. Collapse them to a single "value" option so the key is compatible with
+ // single-component scalar bindings (e.g. opacity), enabling mixed selections.
+ const options =
+ easingMode === "shared"
+ ? [
+ {
+ key: "value",
+ label: "Curve",
+ context: contexts[0].context,
+ allContexts: contexts.map(({ context }) => context),
+ },
+ ]
+ : contexts.map(({ context, option }) => ({
+ key: option.key,
+ label: option.label,
+ context,
+ allContexts: [context],
+ }));
+
+ return {
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: resolvedKeyframeId,
+ secondaryKeyframeId,
+ options,
+ };
+}
+
+function isLinearCurve({
+ cubicBezier,
+}: {
+ cubicBezier: NormalizedCubicBezier;
+}): boolean {
+ return (
+ Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
+ );
+}
+
+function resolveSegmentForOption({
+ propertySelection,
+ componentKey,
+}: {
+ propertySelection: GraphEditorPropertySelection;
+ componentKey: string;
+}):
+ | {
+ segment: GraphEditorResolvedSegment;
+ }
+ | {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ } {
+ const option = propertySelection.options.find(
+ (propertyOption) => propertyOption.key === componentKey,
+ );
+ if (!option) {
+ return {
+ reason: "selected-properties-have-no-shared-component",
+ message: "Selected properties do not share a graph-editable channel.",
+ };
+ }
+
+ if (!option.context.nextKey) {
+ return {
+ reason: "selected-keyframe-has-no-next-segment",
+ message: "Select a keyframe that has an outgoing segment.",
+ };
+ }
+
+ if (
+ propertySelection.secondaryKeyframeId !== null &&
+ option.context.nextKey.id !== propertySelection.secondaryKeyframeId
+ ) {
+ return {
+ reason: "selected-keyframes-are-not-adjacent",
+ message: "Selected keyframes must be adjacent on each property.",
+ };
+ }
+
+ if (option.context.keyframe.segmentToNext === "step") {
+ return {
+ reason: "selected-segment-is-hold",
+ message: "Hold segments have a fixed value - easing has no effect here.",
+ };
+ }
+
+ const referenceSpanValue = getReferenceSpanValue({ context: option.context });
+ const cubicBezier =
+ option.context.keyframe.segmentToNext === "linear"
+ ? GRAPH_LINEAR_CURVE
+ : getNormalizedCubicBezierForScalarSegment({
+ leftKey: option.context.keyframe,
+ rightKey: option.context.nextKey,
+ referenceSpanValue,
+ });
+ if (!cubicBezier) {
+ return {
+ reason: "selected-segment-is-flat",
+ message:
+ "Cannot edit a segment where both keyframes are at the same time.",
+ };
+ }
+
+ return {
+ segment: {
+ propertyPath: propertySelection.propertyPath,
+ keyframeId: propertySelection.keyframeId,
+ context: option.context,
+ allContexts: option.allContexts,
+ cubicBezier,
+ referenceSpanValue,
+ },
+ };
+}
+
+export function resolveGraphEditorSelectionState({
+ tracks,
+ selectedKeyframes,
+ preferredComponentKey,
+}: {
+ tracks: SceneTracks;
+ selectedKeyframes: SelectedKeyframeRef[];
+ preferredComponentKey?: string | null;
+}): GraphEditorSelectionState {
+ if (selectedKeyframes.length === 0) {
+ return createUnavailableState({
+ reason: "no-keyframe-selected",
+ message: "Select a keyframe to edit its curve.",
+ });
+ }
+
+ const propertyKeyframes = groupSelectedKeyframesByProperty({
+ selectedKeyframes,
+ });
+ const primaryKeyframe = propertyKeyframes[0]?.keyframes[0];
+ if (!primaryKeyframe) {
+ return createUnavailableState({
+ reason: "no-keyframe-selected",
+ message: "Select a keyframe to edit its curve.",
+ });
+ }
+
+ const selectedElement = findElementByKeyframe({
+ tracks,
+ keyframe: primaryKeyframe,
+ });
+ if (!selectedElement) {
+ return createUnavailableState({
+ reason: "selected-element-missing",
+ message: "The selected keyframe could not be resolved.",
+ });
+ }
+
+ const spansMultipleElements = propertyKeyframes.some(
+ (propertySelection) =>
+ propertySelection.trackId !== selectedElement.trackId ||
+ propertySelection.elementId !== selectedElement.elementId,
+ );
+ if (spansMultipleElements) {
+ return createUnavailableState({
+ reason: "selected-keyframes-span-multiple-elements",
+ message: "Selected keyframes must be on the same element.",
+ });
+ }
+
+ const propertySelections = propertyKeyframes.map((propertySelection) =>
+ resolvePropertySelection({
+ element: selectedElement.element,
+ propertyKeyframes: propertySelection,
+ }),
+ );
+ const unavailablePropertySelection = propertySelections.find(
+ (propertySelection) => "reason" in propertySelection,
+ );
+ if (
+ unavailablePropertySelection &&
+ "reason" in unavailablePropertySelection
+ ) {
+ return createUnavailableState({
+ reason: unavailablePropertySelection.reason,
+ message: unavailablePropertySelection.message,
+ });
+ }
+
+ const resolvedPropertySelections = propertySelections.filter(
+ (propertySelection): propertySelection is GraphEditorPropertySelection =>
+ !("reason" in propertySelection),
+ );
+ const sharedComponentOptions =
+ resolvedPropertySelections[0]?.options.filter((componentOption) =>
+ resolvedPropertySelections.every((propertySelection) =>
+ propertySelection.options.some(
+ (option) => option.key === componentOption.key,
+ ),
+ ),
+ ) ?? [];
+ const componentOptions = sharedComponentOptions.map(({ key, label }) => ({
+ key,
+ label,
+ }));
+ if (componentOptions.length === 0) {
+ return createUnavailableState({
+ reason: "selected-properties-have-no-shared-component",
+ message: "Selected properties do not share a graph-editable channel.",
+ });
+ }
+
+ // Try each component option in preference order (preferred first, then the rest)
+ // and stop at the first key where every property resolves to a valid segment.
+ // This single pass both selects the active key and produces the segment list.
+ const candidateKeys = [
+ ...(preferredComponentKey &&
+ componentOptions.some((option) => option.key === preferredComponentKey)
+ ? [preferredComponentKey]
+ : []),
+ ...componentOptions
+ .filter((option) => option.key !== preferredComponentKey)
+ .map((option) => option.key),
+ ];
+
+ let activeComponentKey = componentOptions[0].key;
+ let segmentResults: ReturnType<typeof resolveSegmentForOption>[] = [];
+
+ for (const candidateKey of candidateKeys) {
+ const results = resolvedPropertySelections.map((propertySelection) =>
+ resolveSegmentForOption({
+ propertySelection,
+ componentKey: candidateKey,
+ }),
+ );
+ activeComponentKey = candidateKey;
+ segmentResults = results;
+ if (results.every((result) => "segment" in result)) {
+ break;
+ }
+ }
+
+ const unavailableSegment = segmentResults.find(
+ (result) => !("segment" in result),
+ );
+ if (unavailableSegment && !("segment" in unavailableSegment)) {
+ return createUnavailableState({
+ reason: unavailableSegment.reason,
+ message: unavailableSegment.message,
+ componentOptions,
+ activeComponentKey,
+ });
+ }
+
+ const segments = segmentResults.flatMap((result) =>
+ "segment" in result ? [result.segment] : [],
+ );
+ const primarySegment = segments[0];
+ if (!primarySegment) {
+ return createUnavailableState({
+ reason: "selected-keyframe-missing-on-channel",
+ message: "The selected keyframe is not editable as a graph segment.",
+ componentOptions,
+ activeComponentKey,
+ });
+ }
+
+ return {
+ status: "ready",
+ message:
+ segments.length === 1
+ ? "Edit graph"
+ : `Edit graph for ${segments.length} properties`,
+ componentOptions,
+ activeComponentKey,
+ trackId: selectedElement.trackId,
+ elementId: selectedElement.elementId,
+ element: selectedElement.element,
+ segments,
+ cubicBezier: primarySegment.cubicBezier,
+ };
+}
+
+export function buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+ referenceSpanValue,
+}: {
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
+}): GraphEditorCurvePatch[] | null {
+ if (!context.nextKey) {
+ return null;
+ }
+
+ if (isLinearCurve({ cubicBezier })) {
+ return [
+ {
+ keyframeId: context.keyframe.id,
+ patch: {
+ segmentToNext: "linear",
+ rightHandle: null,
+ },
+ },
+ {
+ keyframeId: context.nextKey.id,
+ patch: {
+ leftHandle: null,
+ },
+ },
+ ];
+ }
+
+ const handles = getCurveHandlesForNormalizedCubicBezier({
+ leftKey: context.keyframe,
+ rightKey: context.nextKey,
+ cubicBezier,
+ referenceSpanValue,
+ });
+ if (!handles) {
+ return null;
+ }
+
+ return [
+ {
+ keyframeId: context.keyframe.id,
+ patch: {
+ segmentToNext: "bezier",
+ rightHandle: handles.rightHandle,
+ },
+ },
+ {
+ keyframeId: context.nextKey.id,
+ patch: {
+ leftHandle: handles.leftHandle,
+ },
+ },
+ ];
+}
+
+export function applyGraphEditorCurvePreview({
+ animations,
+ context,
+ cubicBezier,
+ referenceSpanValue,
+}: {
+ animations: ElementAnimations | undefined;
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
+}): ElementAnimations | undefined {
+ const patches = buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+ referenceSpanValue,
+ });
+ if (!patches) {
+ return animations;
+ }
+
+ return patches.reduce<ElementAnimations | undefined>(
+ (nextAnimations, { keyframeId, patch }) =>
+ updateScalarKeyframeCurve({
+ animations: nextAnimations,
+ propertyPath: context.propertyPath,
+ componentKey: context.componentKey,
+ keyframeId,
+ patch,
+ }),
+ animations,
+ );
+}