diff --git a/theatre/core/src/projects/store/types/SheetState_Historic.ts b/theatre/core/src/projects/store/types/SheetState_Historic.ts index 6b527d8..acab5e2 100644 --- a/theatre/core/src/projects/store/types/SheetState_Historic.ts +++ b/theatre/core/src/projects/store/types/SheetState_Historic.ts @@ -71,6 +71,8 @@ export type HistoricPositionalSequence = { */ export type TrackData = BasicKeyframedTrack +export type KeyframeType = 'bezier' | 'hold' + export type Keyframe = { id: KeyframeId /** The `value` is the raw value type such as `Rgba` or `number`. See {@link SerializableValue} */ @@ -80,6 +82,8 @@ export type Keyframe = { position: number handles: [leftX: number, leftY: number, rightX: number, rightY: number] connectedRight: boolean + // defaults to 'bezier' to support project states made with theatre0.5.1 or earlier + type?: KeyframeType } type TrackDataCommon = { diff --git a/theatre/core/src/sequences/interpolationTripleAtPosition.ts b/theatre/core/src/sequences/interpolationTripleAtPosition.ts index ad3fd89..c5fbe32 100644 --- a/theatre/core/src/sequences/interpolationTripleAtPosition.ts +++ b/theatre/core/src/sequences/interpolationTripleAtPosition.ts @@ -184,12 +184,6 @@ const states = { } } - const solver = new UnitBezier( - left.handles[2], - left.handles[3], - right.handles[0], - right.handles[1], - ) const globalProgressionToLocalProgression = ( globalProgression: number, ): number => { @@ -197,12 +191,41 @@ const states = { (globalProgression - left.position) / (right.position - left.position) ) } - const der = prism(() => { + + if (!left.type || left.type === 'bezier') { + const solver = new UnitBezier( + left.handles[2], + left.handles[3], + right.handles[0], + right.handles[1], + ) + + const bezierDer = prism(() => { + const progression = globalProgressionToLocalProgression( + progressionD.getValue(), + ) + const valueProgression = solver.solveSimple(progression) + + return { + left: left.value, + right: right.value, + progression: valueProgression, + } + }) + + return { + started: true, + validFrom: left.position, + validTo: right.position, + der: bezierDer, + } + } + + const holdDer = prism(() => { const progression = globalProgressionToLocalProgression( progressionD.getValue(), ) - - const valueProgression = solver.solveSimple(progression) + const valueProgression = Math.floor(progression) return { left: left.value, right: right.value, @@ -214,7 +237,7 @@ const states = { started: true, validFrom: left.position, validTo: right.position, - der, + der: holdDer, } }, error: { diff --git a/theatre/core/src/sheetObjects/SheetObject.test.ts b/theatre/core/src/sheetObjects/SheetObject.test.ts index 4d9d214..4bd5da2 100644 --- a/theatre/core/src/sheetObjects/SheetObject.test.ts +++ b/theatre/core/src/sheetObjects/SheetObject.test.ts @@ -274,6 +274,7 @@ describe(`SheetObject`, () => { position: 10, connectedRight: true, handles: [0.5, 0.5, 0.5, 0.5], + type: 'bezier', value: 3, }, { @@ -281,6 +282,7 @@ describe(`SheetObject`, () => { position: 20, connectedRight: false, handles: [0.5, 0.5, 0.5, 0.5], + type: 'bezier', value: 6, }, ], diff --git a/theatre/studio/src/StudioStore/createTransactionPrivateApi.ts b/theatre/studio/src/StudioStore/createTransactionPrivateApi.ts index beaf477..b7bc449 100644 --- a/theatre/studio/src/StudioStore/createTransactionPrivateApi.ts +++ b/theatre/studio/src/StudioStore/createTransactionPrivateApi.ts @@ -144,6 +144,7 @@ export default function createTransactionPrivateApi( position: seq.position, value: value as $FixMe, snappingFunction: seq.closestGridPosition, + type: 'bezier', }, ) } else { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx index bc9eb92..a2fa8db 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx @@ -514,6 +514,7 @@ function pasteKeyframesToMultipleTracks( handles: keyframe.handles, value: keyframe.value, snappingFunction: sequence.closestGridPosition, + type: keyframe.type, }, ) } @@ -548,6 +549,7 @@ function pasteKeyframesToSpecificTracks( handles: keyframe.handles, value: keyframe.value, snappingFunction: sequence.closestGridPosition, + type: keyframe.type, }, ) } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx index c11dc75..1254844 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx @@ -187,6 +187,7 @@ function pasteKeyframesContextMenuItem( handles: keyframe.handles, value: keyframe.value, snappingFunction: sequence.closestGridPosition, + type: keyframe.type, }, ) } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx index 1c3c65c..b658197 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx @@ -416,12 +416,14 @@ function setTempValue( tempTransaction.current = null const handles = handlesFromCssCubicBezierArgs(newCurveCssCubicBezier) - if (handles === null) return - - tempTransaction.current = transactionSetCubicBezier( - keyframeConnections, - handles, - ) + if (handles === null) { + tempTransaction.current = transactionSetHold(keyframeConnections) + } else { + tempTransaction.current = transactionSetCubicBezier( + keyframeConnections, + handles, + ) + } } function discardTempValue( @@ -436,7 +438,7 @@ function transactionSetCubicBezier( handles: CubicBezierHandles, ): CommitOrDiscard { return getStudio().tempTransaction(({stateEditors}) => { - const {setHandlesForKeyframe} = + const {setHandlesForKeyframe, setKeyframeType: setKeyframeType} = stateEditors.coreByProject.historic.sheetsById.sequence for (const { @@ -463,6 +465,40 @@ function transactionSetCubicBezier( keyframeId: right.id, end: [handles[2], handles[3]], }) + setKeyframeType({ + projectId, + sheetId, + objectKey, + trackId, + keyframeId: left.id, + keyframeType: 'bezier', + }) + } + }) +} + +function transactionSetHold( + keyframeConnections: Array, +): CommitOrDiscard { + return getStudio().tempTransaction(({stateEditors}) => { + const {setKeyframeType: setKeyframeType} = + stateEditors.coreByProject.historic.sheetsById.sequence + + for (const { + projectId, + sheetId, + objectKey, + trackId, + left, + } of keyframeConnections) { + setKeyframeType({ + projectId, + sheetId, + objectKey, + trackId, + keyframeId: left.id, + keyframeType: 'hold', + }) } }) } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx index 804f1b3..98bb720 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx @@ -111,6 +111,8 @@ const CurveSegmentEditor: React.VFC = (props) => { 1 - connection.right.handles[1], )} 1 ${toExtremumSpace(0)}` + const holdPointsAttrValue = `0,100 100,100 100,0` + return ( = (props) => { fill="url(#dot-background-pattern-2)" /> - {/* Line from right end of curve to right handle */} - - {/* Line from left end of curve to left handle */} - + {!left.type || left.type === 'bezier' ? ( + <> + {/* Line from right end of curve to right handle */} + + {/* Line from left end of curve to left handle */} + + {/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */} + + {/* The background curves (e.g. multiple different values) */} + {backgroundConnections.map((connection, i) => ( + + ))} + {/* The curve */} + + {/* Right end of curve */} + + {/* Left end of curve */} + - {/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */} - - {/* The background curves (e.g. multiple different values) */} - {backgroundConnections.map((connection, i) => ( - - ))} - {/* The curve */} - - {/* Right end of curve */} - - {/* Left end of curve */} - - - {/* Right handle and hit zone */} - - - {/* Left handle and hit zone */} - - + {/* Right handle and hit zone */} + + + {/* Left handle and hit zone */} + + + + ) : ( + <> + + + + + + )} ) } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/SVGCurveSegment.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/SVGCurveSegment.tsx index c5a9f11..19973f3 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/SVGCurveSegment.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/SVGCurveSegment.tsx @@ -21,20 +21,85 @@ type IProps = { const SVGCurveSegment: React.FC = (props) => { const {easing, isSelected} = props - - if (!easing) return <> - const curveColor = isSelected ? SELECTED_CURVE_COLOR : CURVE_COLOR - - const leftControlPoint = [easing[0], toVerticalSVGSpace(easing[1])] - const rightControlPoint = [easing[2], toVerticalSVGSpace(easing[3])] - // With a padding of 0, this results in a "unit viewbox" i.e. `0 0 1 1`. // With padding e.g. VIEWBOX_PADDING=0.1, this results in a viewbox of `-0.1 -0,1 1.2 1.2`, // i.e. a viewbox with a top left coordinate of -0.1,-0.1 and a width and height of 1.2, // resulting in bottom right coordinate of 1.1,1.1 const SVG_VIEWBOX_ATTR = `${-VIEWBOX_PADDING} ${-VIEWBOX_PADDING} ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}` + // Bezier SVG + if (easing) { + const leftControlPoint = [easing[0], toVerticalSVGSpace(easing[1])] + const rightControlPoint = [easing[2], toVerticalSVGSpace(easing[3])] + return ( + + {/* Control lines */} + + + + {/* Control point hitzonecircles */} + + + + {/* Control point circles */} + + + + {/* Bezier curve */} + + + + + ) + } + + // "Hold" SVG return ( = (props) => { fill="none" xmlns="http://www.w3.org/2000/svg" > - {/* Control lines */} - - {/* Control point hitzonecircles */} - - - - {/* Control point circles */} - - - - {/* Bezier curve */} - diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/shared.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/shared.ts index 732c744..bd874d4 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/shared.ts +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/shared.ts @@ -85,6 +85,7 @@ export const EASING_PRESETS = [ {label: 'linear', value: '0.5, 0.5, 0.5, 0.5'}, {label: 'In Out', value: '0.42,0,0.58,1'}, + {label: 'Hold', value: '0, 0, Infinity, Infinity'}, /* These easings are not being included initially in order to simplify the choices */ diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx index a675d0b..b0de73f 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx @@ -74,6 +74,19 @@ const Diamond = styled.div` pointer-events: none; ` +const Square = styled.div` + position: absolute; + ${absoluteDims(DOT_SIZE_PX * 1.5)} + + background: ${(props) => selectBacgroundForDiamond(props)}; + + ${(props) => + props.flag === PresenceFlag.Primary ? 'outline: 2px solid white;' : ''}; + + z-index: 1; + pointer-events: none; +` + const HitZone = styled.div<{isInlineEditorPopoverOpen: boolean}>` z-index: 1; cursor: ew-resize; @@ -121,6 +134,8 @@ const SingleKeyframeDot: React.VFC = (props) => { }, }) + const showDiamond = !props.keyframe.type || props.keyframe.type === 'bezier' + return ( <> = (props) => { isInlineEditorPopoverOpen={isInlineEditorPopoverOpen} {...presence.attrs} /> - + {showDiamond ? ( + + ) : ( + + )} {inlineEditorPopover} {contextMenu} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Curve.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Curve.tsx index 313c0be..f82e485 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Curve.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Curve.tsx @@ -15,6 +15,9 @@ const SVGPath = styled.path` type IProps = Parameters[0] +// for keyframe.type === 'hold' +const pathForHoldType = `M 0 0 L 1 0 L 1 1` + const Curve: React.VFC = (props) => { const {index, trackData} = props const cur = trackData.keyframes[index] @@ -56,7 +59,7 @@ const Curve: React.VFC = (props) => { <> = (props) => { {shouldShowCurve ? ( <> - - + {!cur.type || + (cur.type === 'bezier' && ( + <> + + + + ))} ) : ( noConnector diff --git a/theatre/studio/src/store/stateEditors.ts b/theatre/studio/src/store/stateEditors.ts index f688fba..06176d3 100644 --- a/theatre/studio/src/store/stateEditors.ts +++ b/theatre/studio/src/store/stateEditors.ts @@ -2,6 +2,7 @@ import type { BasicKeyframedTrack, HistoricPositionalSequence, Keyframe, + KeyframeType, SheetState_Historic, } from '@theatre/core/projects/store/types/SheetState_Historic' import type {Drafts} from '@theatre/studio/StudioStore/StudioStore' @@ -696,6 +697,17 @@ namespace stateEditors { return _ensureTracksOfObject(p).trackData[p.trackId] } + function _getKeyframeById( + p: WithoutSheetInstance & { + trackId: SequenceTrackId + keyframeId: KeyframeId + }, + ): Keyframe | undefined { + const track = _getTrack(p) + if (!track) return + return track.keyframes.find((kf) => kf.id === p.keyframeId) + } + /** * Sets a keyframe at the exact specified position. * Any position snapping should be done by the caller. @@ -707,6 +719,7 @@ namespace stateEditors { handles?: [number, number, number, number] value: T snappingFunction: SnappingFunction + type?: KeyframeType }, ) { const position = p.snappingFunction(p.position) @@ -734,6 +747,7 @@ namespace stateEditors { position, connectedRight: true, handles: p.handles || [0.5, 1, 0.5, 0], + type: p.type || 'bezier', value: p.value, }) return @@ -744,6 +758,7 @@ namespace stateEditors { position, connectedRight: leftKeyframe.connectedRight, handles: p.handles || [0.5, 1, 0.5, 0], + type: p.type || 'bezier', value: p.value, }) } @@ -868,24 +883,15 @@ namespace stateEditors { end?: [number, number] }, ) { - const track = _getTrack(p) - if (!track) return - track.keyframes = track.keyframes.map((kf) => { - if (kf.id === p.keyframeId) { - // Use given value or fallback to original value, - // allowing the caller to customize exactly which side - // of the curve they are editing. - return { - ...kf, - handles: [ - p.end?.[0] ?? kf.handles[0], - p.end?.[1] ?? kf.handles[1], - p.start?.[0] ?? kf.handles[2], - p.start?.[1] ?? kf.handles[3], - ], - } - } else return kf - }) + const keyframe = _getKeyframeById(p) + if (keyframe) { + keyframe.handles = [ + p.end?.[0] ?? keyframe.handles[0], + p.end?.[1] ?? keyframe.handles[1], + p.start?.[0] ?? keyframe.handles[2], + p.start?.[1] ?? keyframe.handles[3], + ] + } } export function deleteKeyframes( @@ -902,6 +908,19 @@ namespace stateEditors { ) } + export function setKeyframeType( + p: WithoutSheetInstance & { + trackId: SequenceTrackId + keyframeId: KeyframeId + keyframeType: KeyframeType + }, + ) { + const kf = _getKeyframeById(p) + if (kf) { + kf.type = p.keyframeType + } + } + // Future: consider whether a list of "partial" keyframes requiring `id` is possible to accept // * Consider how common this pattern is, as this sort of concept would best be encountered // a few times to start to see an opportunity for improved ergonomics / crdt.