mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: graph editor for flat keyframe segments
This commit is contained in:
@@ -64,6 +64,12 @@ export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
|
|||||||
*/
|
*/
|
||||||
allContexts: ScalarGraphKeyframeContext[];
|
allContexts: ScalarGraphKeyframeContext[];
|
||||||
cubicBezier: NormalizedCubicBezier;
|
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 =
|
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,
|
||||||
}: {
|
}: {
|
||||||
context: ScalarGraphKeyframeContext;
|
context: ScalarGraphKeyframeContext;
|
||||||
}): boolean {
|
}): number {
|
||||||
if (!context.nextKey) {
|
const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time);
|
||||||
return true;
|
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 (
|
if (rightIndex !== -1) {
|
||||||
Math.abs(context.nextKey.value - context.keyframe.value) <=
|
for (let i = rightIndex; i < sorted.length - 1; i++) {
|
||||||
FLAT_VALUE_EPSILON
|
const span = Math.abs(sorted[i + 1].value - sorted[i].value);
|
||||||
);
|
if (span > FLAT_VALUE_EPSILON) return span;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLinearCurve({
|
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") {
|
if (activeContext.context.keyframe.segmentToNext === "step") {
|
||||||
return createUnavailableState({
|
return createUnavailableState({
|
||||||
reason: "selected-segment-is-hold",
|
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,
|
componentOptions,
|
||||||
activeComponentKey: activeContext.option.key,
|
activeComponentKey: activeContext.option.key,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const referenceSpanValue = getReferenceSpanValue({ context: activeContext.context });
|
||||||
const cubicBezier =
|
const cubicBezier =
|
||||||
activeContext.context.keyframe.segmentToNext === "linear"
|
activeContext.context.keyframe.segmentToNext === "linear"
|
||||||
? GRAPH_LINEAR_CURVE
|
? GRAPH_LINEAR_CURVE
|
||||||
: getNormalizedCubicBezierForScalarSegment({
|
: getNormalizedCubicBezierForScalarSegment({
|
||||||
leftKey: activeContext.context.keyframe,
|
leftKey: activeContext.context.keyframe,
|
||||||
rightKey: activeContext.context.nextKey,
|
rightKey: activeContext.context.nextKey,
|
||||||
|
referenceSpanValue,
|
||||||
});
|
});
|
||||||
if (!cubicBezier) {
|
if (!cubicBezier) {
|
||||||
return createUnavailableState({
|
return createUnavailableState({
|
||||||
reason: "selected-segment-is-flat",
|
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,
|
componentOptions,
|
||||||
activeComponentKey: activeContext.option.key,
|
activeComponentKey: activeContext.option.key,
|
||||||
});
|
});
|
||||||
@@ -375,15 +390,18 @@ export function resolveGraphEditorSelectionState({
|
|||||||
context: activeContext.context,
|
context: activeContext.context,
|
||||||
allContexts,
|
allContexts,
|
||||||
cubicBezier,
|
cubicBezier,
|
||||||
|
referenceSpanValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGraphEditorCurvePatches({
|
export function buildGraphEditorCurvePatches({
|
||||||
context,
|
context,
|
||||||
cubicBezier,
|
cubicBezier,
|
||||||
|
referenceSpanValue,
|
||||||
}: {
|
}: {
|
||||||
context: ScalarGraphKeyframeContext;
|
context: ScalarGraphKeyframeContext;
|
||||||
cubicBezier: NormalizedCubicBezier;
|
cubicBezier: NormalizedCubicBezier;
|
||||||
|
referenceSpanValue: number;
|
||||||
}): GraphEditorCurvePatch[] | null {
|
}): GraphEditorCurvePatch[] | null {
|
||||||
if (!context.nextKey) {
|
if (!context.nextKey) {
|
||||||
return null;
|
return null;
|
||||||
@@ -411,6 +429,7 @@ export function buildGraphEditorCurvePatches({
|
|||||||
leftKey: context.keyframe,
|
leftKey: context.keyframe,
|
||||||
rightKey: context.nextKey,
|
rightKey: context.nextKey,
|
||||||
cubicBezier,
|
cubicBezier,
|
||||||
|
referenceSpanValue,
|
||||||
});
|
});
|
||||||
if (!handles) {
|
if (!handles) {
|
||||||
return null;
|
return null;
|
||||||
@@ -437,14 +456,17 @@ export function applyGraphEditorCurvePreview({
|
|||||||
animations,
|
animations,
|
||||||
context,
|
context,
|
||||||
cubicBezier,
|
cubicBezier,
|
||||||
|
referenceSpanValue,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
context: ScalarGraphKeyframeContext;
|
context: ScalarGraphKeyframeContext;
|
||||||
cubicBezier: NormalizedCubicBezier;
|
cubicBezier: NormalizedCubicBezier;
|
||||||
|
referenceSpanValue: number;
|
||||||
}): ElementAnimations | undefined {
|
}): ElementAnimations | undefined {
|
||||||
const patches = buildGraphEditorCurvePatches({
|
const patches = buildGraphEditorCurvePatches({
|
||||||
context,
|
context,
|
||||||
cubicBezier,
|
cubicBezier,
|
||||||
|
referenceSpanValue,
|
||||||
});
|
});
|
||||||
if (!patches) {
|
if (!patches) {
|
||||||
return animations;
|
return animations;
|
||||||
|
|||||||
@@ -96,11 +96,16 @@ export function useGraphEditorController() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAnimations = state.allContexts.reduce(
|
const nextAnimations = state.allContexts.reduce(
|
||||||
(animations, context) =>
|
(animations, context) =>
|
||||||
applyGraphEditorCurvePreview({ animations, context, cubicBezier: nextValue }),
|
applyGraphEditorCurvePreview({
|
||||||
state.element.animations,
|
animations,
|
||||||
);
|
context,
|
||||||
|
cubicBezier: nextValue,
|
||||||
|
referenceSpanValue: state.referenceSpanValue,
|
||||||
|
}),
|
||||||
|
state.element.animations,
|
||||||
|
);
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
@@ -123,10 +128,11 @@ export function useGraphEditorController() {
|
|||||||
|
|
||||||
// Build patches from the primary context (all shared-easing channels have
|
// Build patches from the primary context (all shared-easing channels have
|
||||||
// the same keyframe IDs, so the same patches apply to each).
|
// the same keyframe IDs, so the same patches apply to each).
|
||||||
const patches = buildGraphEditorCurvePatches({
|
const patches = buildGraphEditorCurvePatches({
|
||||||
context: state.context,
|
context: state.context,
|
||||||
cubicBezier: nextValue,
|
cubicBezier: nextValue,
|
||||||
});
|
referenceSpanValue: state.referenceSpanValue,
|
||||||
|
});
|
||||||
if (!patches) {
|
if (!patches) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,23 @@ function clamp01({ value }: { value: number }): number {
|
|||||||
export function getNormalizedCubicBezierForScalarSegment({
|
export function getNormalizedCubicBezierForScalarSegment({
|
||||||
leftKey,
|
leftKey,
|
||||||
rightKey,
|
rightKey,
|
||||||
|
referenceSpanValue,
|
||||||
}: {
|
}: {
|
||||||
leftKey: ScalarAnimationKey;
|
leftKey: ScalarAnimationKey;
|
||||||
rightKey: ScalarAnimationKey;
|
rightKey: ScalarAnimationKey;
|
||||||
|
/** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
|
||||||
|
referenceSpanValue?: number;
|
||||||
}): NormalizedCubicBezier | null {
|
}): NormalizedCubicBezier | null {
|
||||||
const spanTime = rightKey.time - leftKey.time;
|
const spanTime = rightKey.time - leftKey.time;
|
||||||
const spanValue = rightKey.value - leftKey.value;
|
const spanValue = rightKey.value - leftKey.value;
|
||||||
if (
|
const effectiveSpanValue =
|
||||||
spanTime === 0 ||
|
Math.abs(spanValue) > VALUE_EPSILON
|
||||||
Math.abs(spanValue) <= VALUE_EPSILON
|
? spanValue
|
||||||
) {
|
: referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
|
||||||
|
? referenceSpanValue
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (spanTime === 0 || effectiveSpanValue === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +44,9 @@ export function getNormalizedCubicBezierForScalarSegment({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
clamp01({ value: rightHandle.dt / spanTime }),
|
clamp01({ value: rightHandle.dt / spanTime }),
|
||||||
rightHandle.dv / spanValue,
|
rightHandle.dv / effectiveSpanValue,
|
||||||
clamp01({ value: 1 + leftHandle.dt / spanTime }),
|
clamp01({ value: 1 + leftHandle.dt / spanTime }),
|
||||||
1 + leftHandle.dv / spanValue,
|
1 + leftHandle.dv / effectiveSpanValue,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,20 +54,27 @@ export function getCurveHandlesForNormalizedCubicBezier({
|
|||||||
leftKey,
|
leftKey,
|
||||||
rightKey,
|
rightKey,
|
||||||
cubicBezier,
|
cubicBezier,
|
||||||
|
referenceSpanValue,
|
||||||
}: {
|
}: {
|
||||||
leftKey: ScalarAnimationKey;
|
leftKey: ScalarAnimationKey;
|
||||||
rightKey: ScalarAnimationKey;
|
rightKey: ScalarAnimationKey;
|
||||||
cubicBezier: NormalizedCubicBezier;
|
cubicBezier: NormalizedCubicBezier;
|
||||||
|
/** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
|
||||||
|
referenceSpanValue?: number;
|
||||||
}): {
|
}): {
|
||||||
rightHandle: CurveHandle;
|
rightHandle: CurveHandle;
|
||||||
leftHandle: CurveHandle;
|
leftHandle: CurveHandle;
|
||||||
} | null {
|
} | null {
|
||||||
const spanTime = rightKey.time - leftKey.time;
|
const spanTime = rightKey.time - leftKey.time;
|
||||||
const spanValue = rightKey.value - leftKey.value;
|
const spanValue = rightKey.value - leftKey.value;
|
||||||
if (
|
const effectiveSpanValue =
|
||||||
spanTime === 0 ||
|
Math.abs(spanValue) > VALUE_EPSILON
|
||||||
Math.abs(spanValue) <= VALUE_EPSILON
|
? spanValue
|
||||||
) {
|
: referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
|
||||||
|
? referenceSpanValue
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (spanTime === 0 || effectiveSpanValue === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
|
|||||||
return {
|
return {
|
||||||
rightHandle: {
|
rightHandle: {
|
||||||
dt: spanTime * x1,
|
dt: spanTime * x1,
|
||||||
dv: spanValue * y1,
|
dv: effectiveSpanValue * y1,
|
||||||
},
|
},
|
||||||
leftHandle: {
|
leftHandle: {
|
||||||
dt: spanTime * (x2 - 1),
|
dt: spanTime * (x2 - 1),
|
||||||
dv: spanValue * (y2 - 1),
|
dv: effectiveSpanValue * (y2 - 1),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user