summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-13 21:59:50 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-13 21:59:50 +0200
commit270e2951b188af067c3d9ae915282c8db1af2b81 (patch)
tree910d0f2b578c36fdc82996acfcd692dc923ae40a /apps/web/src
parent496f7d690dd05faefd12b69ceab598b396782b74 (diff)
downloadopencut-270e2951b188af067c3d9ae915282c8db1af2b81.tar.gz
feat: graph editor for flat keyframe segments
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts60
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts24
-rw-r--r--apps/web/src/lib/animation/curve-bridge.ts38
3 files changed, 82 insertions, 40 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
index 663b4b5a..60e4723e 100644
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
@@ -64,6 +64,12 @@ export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
*/
allContexts: ScalarGraphKeyframeContext[];
cubicBezier: NormalizedCubicBezier;
+ /**
+ * Y-axis scale used for flat segments (where spanValue ≈ 0). Derived from
+ * the nearest non-flat adjacent segment so that handle positions correspond
+ * to a meaningful value range. Always positive.
+ */
+ referenceSpanValue: number;
}
export type GraphEditorSelectionState =
@@ -155,19 +161,35 @@ function getComponentLabel({ componentKey }: { componentKey: string }): string {
}
}
-function isFlatSegment({
+/**
+ * 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;
-}): boolean {
- if (!context.nextKey) {
- return true;
+}): 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;
}
- return (
- Math.abs(context.nextKey.value - context.keyframe.value) <=
- FLAT_VALUE_EPSILON
- );
+ 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;
}
function isLinearCurve({
@@ -328,35 +350,28 @@ export function resolveGraphEditorSelectionState({
});
}
- if (isFlatSegment({ context: activeContext.context })) {
- return createUnavailableState({
- reason: "selected-segment-is-flat",
- message: "Flat segments are not graph-editable in this popover yet.",
- componentOptions,
- activeComponentKey: activeContext.option.key,
- });
- }
-
if (activeContext.context.keyframe.segmentToNext === "step") {
return createUnavailableState({
reason: "selected-segment-is-hold",
- message: "Hold segments are not graph-editable in this popover yet.",
+ message: "Hold segments have a fixed value — easing has no effect here.",
componentOptions,
activeComponentKey: activeContext.option.key,
});
}
+ const referenceSpanValue = getReferenceSpanValue({ context: activeContext.context });
const cubicBezier =
activeContext.context.keyframe.segmentToNext === "linear"
? GRAPH_LINEAR_CURVE
: getNormalizedCubicBezierForScalarSegment({
leftKey: activeContext.context.keyframe,
rightKey: activeContext.context.nextKey,
+ referenceSpanValue,
});
if (!cubicBezier) {
return createUnavailableState({
reason: "selected-segment-is-flat",
- message: "The selected segment cannot be represented in this graph view.",
+ message: "Cannot edit a segment where both keyframes are at the same time.",
componentOptions,
activeComponentKey: activeContext.option.key,
});
@@ -375,15 +390,18 @@ export function resolveGraphEditorSelectionState({
context: activeContext.context,
allContexts,
cubicBezier,
+ referenceSpanValue,
};
}
export function buildGraphEditorCurvePatches({
context,
cubicBezier,
+ referenceSpanValue,
}: {
context: ScalarGraphKeyframeContext;
cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
}): GraphEditorCurvePatch[] | null {
if (!context.nextKey) {
return null;
@@ -411,6 +429,7 @@ export function buildGraphEditorCurvePatches({
leftKey: context.keyframe,
rightKey: context.nextKey,
cubicBezier,
+ referenceSpanValue,
});
if (!handles) {
return null;
@@ -437,14 +456,17 @@ 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;
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
index 8baa3c43..cd4c792f 100644
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
@@ -96,11 +96,16 @@ export function useGraphEditorController() {
return;
}
- const nextAnimations = state.allContexts.reduce(
- (animations, context) =>
- applyGraphEditorCurvePreview({ animations, context, cubicBezier: nextValue }),
- state.element.animations,
- );
+ const nextAnimations = state.allContexts.reduce(
+ (animations, context) =>
+ applyGraphEditorCurvePreview({
+ animations,
+ context,
+ cubicBezier: nextValue,
+ referenceSpanValue: state.referenceSpanValue,
+ }),
+ state.element.animations,
+ );
editor.timeline.previewElements({
updates: [
{
@@ -123,10 +128,11 @@ export function useGraphEditorController() {
// Build patches from the primary context (all shared-easing channels have
// the same keyframe IDs, so the same patches apply to each).
- const patches = buildGraphEditorCurvePatches({
- context: state.context,
- cubicBezier: nextValue,
- });
+ const patches = buildGraphEditorCurvePatches({
+ context: state.context,
+ cubicBezier: nextValue,
+ referenceSpanValue: state.referenceSpanValue,
+ });
if (!patches) {
return;
}
diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts
index 12103c3f..385fb448 100644
--- a/apps/web/src/lib/animation/curve-bridge.ts
+++ b/apps/web/src/lib/animation/curve-bridge.ts
@@ -17,16 +17,23 @@ function clamp01({ value }: { value: number }): number {
export function getNormalizedCubicBezierForScalarSegment({
leftKey,
rightKey,
+ referenceSpanValue,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
+ /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
+ referenceSpanValue?: number;
}): NormalizedCubicBezier | null {
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
- if (
- spanTime === 0 ||
- Math.abs(spanValue) <= VALUE_EPSILON
- ) {
+ const effectiveSpanValue =
+ Math.abs(spanValue) > VALUE_EPSILON
+ ? spanValue
+ : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
+ ? referenceSpanValue
+ : null;
+
+ if (spanTime === 0 || effectiveSpanValue === null) {
return null;
}
@@ -37,9 +44,9 @@ export function getNormalizedCubicBezierForScalarSegment({
return [
clamp01({ value: rightHandle.dt / spanTime }),
- rightHandle.dv / spanValue,
+ rightHandle.dv / effectiveSpanValue,
clamp01({ value: 1 + leftHandle.dt / spanTime }),
- 1 + leftHandle.dv / spanValue,
+ 1 + leftHandle.dv / effectiveSpanValue,
];
}
@@ -47,20 +54,27 @@ export function getCurveHandlesForNormalizedCubicBezier({
leftKey,
rightKey,
cubicBezier,
+ referenceSpanValue,
}: {
leftKey: ScalarAnimationKey;
rightKey: ScalarAnimationKey;
cubicBezier: NormalizedCubicBezier;
+ /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
+ referenceSpanValue?: number;
}): {
rightHandle: CurveHandle;
leftHandle: CurveHandle;
} | null {
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
- if (
- spanTime === 0 ||
- Math.abs(spanValue) <= VALUE_EPSILON
- ) {
+ const effectiveSpanValue =
+ Math.abs(spanValue) > VALUE_EPSILON
+ ? spanValue
+ : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
+ ? referenceSpanValue
+ : null;
+
+ if (spanTime === 0 || effectiveSpanValue === null) {
return null;
}
@@ -71,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
return {
rightHandle: {
dt: spanTime * x1,
- dv: spanValue * y1,
+ dv: effectiveSpanValue * y1,
},
leftHandle: {
dt: spanTime * (x2 - 1),
- dv: spanValue * (y2 - 1),
+ dv: effectiveSpanValue * (y2 - 1),
},
};
}