diff --git a/packages/core/src/interpolate.ts b/packages/core/src/interpolate.ts index 145627c3679..c7014a120ee 100644 --- a/packages/core/src/interpolate.ts +++ b/packages/core/src/interpolate.ts @@ -33,6 +33,12 @@ type ParsedStringInterpolationValue = { dimensions: number; }; +type TransformOriginAxis = 'x' | 'y'; +type TransformOriginAxisValue = { + value: number; + unit: string; +}; + type NumericTuple = readonly [number, ...number[]]; type InterpolateOutputValue = number | string | readonly number[]; type WidenNumericTuple = { @@ -78,6 +84,40 @@ const lengthUnits = new Set([ ]); const cssNumberRegex = /^([+-]?(?:\d+\.?\d*|\.\d+))([a-zA-Z%]+)?$/; +const transformOriginKeywords = new Set([ + 'left', + 'center', + 'right', + 'top', + 'bottom', +]); + +const transformOriginKeywordOptions = ( + keyword: string, +): {axis: TransformOriginAxis; value: TransformOriginAxisValue}[] => { + if (keyword === 'left') { + return [{axis: 'x', value: {value: 0, unit: '%'}}]; + } + + if (keyword === 'right') { + return [{axis: 'x', value: {value: 100, unit: '%'}}]; + } + + if (keyword === 'top') { + return [{axis: 'y', value: {value: 0, unit: '%'}}]; + } + + if (keyword === 'bottom') { + return [{axis: 'y', value: {value: 100, unit: '%'}}]; + } + + return [ + {axis: 'x', value: {value: 50, unit: '%'}}, + {axis: 'y', value: {value: 50, unit: '%'}}, + ]; +}; + +const transformOriginCenter: TransformOriginAxisValue = {value: 50, unit: '%'}; const stringifyNumber = (value: number): string => { return String(normalizeNumber(value)); @@ -123,6 +163,199 @@ const parseStringInterpolationComponent = ( ); }; +const parseTransformOriginLengthPercentage = ({ + component, + value, + allowPercentage, +}: { + component: string; + value: string; + allowPercentage: boolean; +}): TransformOriginAxisValue => { + const match = cssNumberRegex.exec(component); + if (match === null) { + throw new TypeError( + `Cannot interpolate "${value}" because "${component}" is not a supported transform-origin ${allowPercentage ? 'length-percentage' : 'z length'}`, + ); + } + + const unit = match[2] ?? null; + const numberValue = Number(match[1]); + if (!Number.isFinite(numberValue)) { + throw new TypeError( + `Cannot interpolate "${value}" because "${component}" is not finite`, + ); + } + + if ( + unit === null || + !lengthUnits.has(unit) || + (!allowPercentage && unit === '%') + ) { + throw new TypeError( + `Cannot interpolate "${value}" because "${component}" is not a supported transform-origin ${allowPercentage ? 'length-percentage' : 'z length'}`, + ); + } + + return {value: numberValue, unit}; +}; + +const parseTransformOriginToken = ( + component: string, + value: string, +): + | { + type: 'keyword'; + keyword: string; + } + | { + type: 'length-percentage'; + parsed: TransformOriginAxisValue; + } => { + const lower = component.toLowerCase(); + if (transformOriginKeywords.has(lower)) { + return {type: 'keyword', keyword: lower}; + } + + return { + type: 'length-percentage', + parsed: parseTransformOriginLengthPercentage({ + component, + value, + allowPercentage: true, + }), + }; +}; + +const parseTwoTransformOriginKeywords = ( + first: string, + second: string, + value: string, +): [TransformOriginAxisValue, TransformOriginAxisValue] => { + const candidates: [TransformOriginAxisValue, TransformOriginAxisValue][] = []; + for (const firstOption of transformOriginKeywordOptions(first)) { + for (const secondOption of transformOriginKeywordOptions(second)) { + if (firstOption.axis === secondOption.axis) { + continue; + } + + candidates.push( + firstOption.axis === 'x' + ? [firstOption.value, secondOption.value] + : [secondOption.value, firstOption.value], + ); + } + } + + if (candidates.length === 0) { + throw new TypeError( + `Cannot interpolate "${value}" because "${first} ${second}" is not a valid transform-origin keyword pair`, + ); + } + + return candidates[0]; +}; + +const parseTransformOriginXY = ( + parts: string[], + value: string, +): [TransformOriginAxisValue, TransformOriginAxisValue] => { + if (parts.length === 1) { + const token = parseTransformOriginToken(parts[0], value); + if (token.type === 'length-percentage') { + return [token.parsed, transformOriginCenter]; + } + + if (token.keyword === 'top' || token.keyword === 'bottom') { + return [ + transformOriginCenter, + transformOriginKeywordOptions(token.keyword)[0].value, + ]; + } + + return [ + transformOriginKeywordOptions(token.keyword)[0].value, + transformOriginCenter, + ]; + } + + const first = parseTransformOriginToken(parts[0], value); + const second = parseTransformOriginToken(parts[1], value); + + if ( + first.type === 'length-percentage' && + second.type === 'length-percentage' + ) { + return [first.parsed, second.parsed]; + } + + if (first.type === 'keyword' && second.type === 'keyword') { + return parseTwoTransformOriginKeywords( + first.keyword, + second.keyword, + value, + ); + } + + const keyword = + first.type === 'keyword' + ? first + : second.type === 'keyword' + ? second + : null; + const length = + first.type === 'length-percentage' + ? first.parsed + : second.type === 'length-percentage' + ? second.parsed + : null; + if (keyword === null || length === null) { + throw new Error('Expected a keyword and a length-percentage value'); + } + + const keywordIsFirst = first.type === 'keyword'; + + if (keyword.keyword === 'left' || keyword.keyword === 'right') { + if (!keywordIsFirst) { + throw new TypeError( + `Cannot interpolate "${value}" because horizontal transform-origin keywords must come before a length-percentage value`, + ); + } + + return [transformOriginKeywordOptions(keyword.keyword)[0].value, length]; + } + + if (keyword.keyword === 'top' || keyword.keyword === 'bottom') { + return [length, transformOriginKeywordOptions(keyword.keyword)[0].value]; + } + + return keywordIsFirst + ? [transformOriginCenter, length] + : [length, transformOriginCenter]; +}; + +const parseTransformOriginValue = ( + output: string, + parts: string[], +): ParsedStringInterpolationValue => { + const [x, y] = parseTransformOriginXY(parts.slice(0, 2), output); + const z = + parts[2] === undefined + ? {value: 0, unit: null} + : parseTransformOriginLengthPercentage({ + component: parts[2], + value: output, + allowPercentage: false, + }); + + return { + kind: 'translate', + values: [x.value, y.value, z.value], + units: [x.unit, y.unit, z.unit], + dimensions: parts[2] === undefined ? 2 : 3, + }; +}; + const parseStringInterpolationValue = ( output: string | number, ): ParsedStringInterpolationValue => { @@ -148,6 +381,10 @@ const parseStringInterpolationValue = ( ); } + if (parts.some((part) => transformOriginKeywords.has(part.toLowerCase()))) { + return parseTransformOriginValue(output, parts); + } + const parsed = parts.map((part) => parseStringInterpolationComponent(part, output), ); diff --git a/packages/core/src/test/interpolate.test.ts b/packages/core/src/test/interpolate.test.ts index fdcba9cfbb9..9e465481f29 100644 --- a/packages/core/src/test/interpolate.test.ts +++ b/packages/core/src/test/interpolate.test.ts @@ -347,6 +347,29 @@ test('Interpolates translate strings', () => { ); }); +test('Interpolates transform-origin keyword strings', () => { + expect(interpolate(15, [0, 30], ['center', 'right bottom'])).toBe('75% 75%'); + expect(interpolate(15, [0, 30], ['left top', 'right bottom'])).toBe( + '50% 50%', + ); + expect(interpolate(15, [0, 30], ['top left', 'bottom right'])).toBe( + '50% 50%', + ); + expect(interpolate(15, [0, 30], ['top', 'bottom'])).toBe('50% 50%'); + expect(interpolate(15, [0, 30], ['left', 'right'])).toBe('50% 50%'); + expect(interpolate(15, [0, 30], ['left 25%', 'right 75%'])).toBe('50% 50%'); + expect(interpolate(15, [0, 30], ['top 25%', '75% bottom'])).toBe('50% 50%'); +}); + +test('Interpolates transform-origin z components', () => { + expect(interpolate(15, [0, 30], ['left top 10px', 'right bottom 30px'])).toBe( + '50% 50% 20px', + ); + expect(interpolate(15, [0, 30], ['left top', 'right bottom 10px'])).toBe( + '50% 50% 5px', + ); +}); + test('Interpolates rotate strings', () => { expect(interpolate(15, [0, 30], ['0deg', '100deg'])).toBe('50deg'); expect(interpolate(15, [0, 30], ['-10.5deg', '20.5deg'])).toBe('5deg'); @@ -395,6 +418,30 @@ test('String interpolation throws on type and unit mismatches', () => { () => interpolate(15, [0, 30], ['0px', '1px 2px 3px 4px']), /1 to 3 components/, ); + expectToThrow( + () => interpolate(15, [0, 30], ['left', '100px 100px']), + /different units on axis 1/, + ); + expectToThrow( + () => interpolate(15, [0, 30], ['left right', 'right bottom']), + /valid transform-origin keyword pair/, + ); + expectToThrow( + () => interpolate(15, [0, 30], ['top bottom', 'right bottom']), + /valid transform-origin keyword pair/, + ); + expectToThrow( + () => interpolate(15, [0, 30], ['10% left', 'right bottom']), + /horizontal transform-origin keywords/, + ); + expectToThrow( + () => interpolate(15, [0, 30], ['left top 50%', 'right bottom 10px']), + /supported transform-origin z length/, + ); + expectToThrow( + () => interpolate(15, [0, 30], ['left top center', 'right bottom 10px']), + /supported transform-origin z length/, + ); }); test('Easing.circle with default extrapolate extend clamps normalized input', () => { diff --git a/packages/docs/docs/ai/dynamic-compilation.mdx b/packages/docs/docs/ai/dynamic-compilation.mdx index a2773040ea3..99f5f18eb47 100644 --- a/packages/docs/docs/ai/dynamic-compilation.mdx +++ b/packages/docs/docs/ai/dynamic-compilation.mdx @@ -188,17 +188,10 @@ export function useCompilation(code: string): CompilationResult { ```ts title="Example AI Output" -const generatedCode = `import {useCurrentFrame, AbsoluteFill, spring, useVideoConfig} from 'remotion'; +const generatedCode = `import {useCurrentFrame, AbsoluteFill, interpolate, Easing} from 'remotion'; export const MyComposition = () => { const frame = useCurrentFrame(); - const {fps} = useVideoConfig(); - - const scale = spring({ - frame, - fps, - config: {damping: 10, stiffness: 100}, - }); return ( { style={{ fontSize: 120, color: 'white', - transform: \`scale(\${scale})\`, + scale: interpolate(frame, [0, 20], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.bezier(0.34, 1.56, 0.64, 1), + }), }} > Hello World diff --git a/packages/docs/docs/ai/generate.mdx b/packages/docs/docs/ai/generate.mdx index 7e72924ab17..0213168e177 100644 --- a/packages/docs/docs/ai/generate.mdx +++ b/packages/docs/docs/ai/generate.mdx @@ -26,7 +26,8 @@ Generate a single React component that uses Remotion. Rules: - Export the component as a named export called "MyComposition" - Use useCurrentFrame() and useVideoConfig() from "remotion" -- Animate values with interpolate() or spring() as appropriate +- Prefer interpolate() with Easing over spring() unless physics-based motion is explicitly requested +- Use inline interpolate() calls in style props and prefer scale, translate, rotate over transform strings - Only output the code, no markdown or explanations `.trim(); @@ -76,7 +77,8 @@ Generate a React component that uses Remotion. For the code property, ALWAYS dir Rules: - The component should be a named export called "MyComposition" - Use useCurrentFrame() and useVideoConfig() from "remotion" -- Animate with interpolate() (and Easing from "remotion" when needed) or spring() as appropriate +- Prefer interpolate() with Easing from "remotion" over spring() unless physics-based motion is explicitly requested +- Use inline interpolate() calls in style props and prefer scale, translate, rotate over transform strings - Keep it self-contained with no external dependencies `.trim(); diff --git a/packages/docs/docs/interpolate.mdx b/packages/docs/docs/interpolate.mdx index 5baa2fbe5fb..46fe6714109 100644 --- a/packages/docs/docs/interpolate.mdx +++ b/packages/docs/docs/interpolate.mdx @@ -111,7 +111,7 @@ const scale = interpolate(frame, [0, 20], [0, 1], { ## Example: CSS transform values -`outputRange` may contain scale, translate, or rotate strings. +`outputRange` may contain scale, translate, rotate, or transform origin strings. Each value may contain up to three components. ```tsx twoslash title="MyComposition.tsx" @@ -126,6 +126,10 @@ export const MyComposition: React.FC = () => { scale: interpolate(frame, [0, 30], ['1', '2 3']), translate: interpolate(frame, [0, 30], ['0px 0px', '100px 50px']), rotate: interpolate(frame, [0, 30], ['0deg', '90deg']), + transformOrigin: interpolate(frame, [0, 30], [ + 'left top', + 'right bottom', + ]), }} /> ); @@ -135,10 +139,13 @@ export const MyComposition: React.FC = () => { Scale values use unitless numbers. Translate values use length or percentage units. Rotate values use `deg`, `rad`, `grad`, or `turn`. +Transform origin values may use the `left`, `center`, `right`, `top`, and `bottom` keywords from . +They are normalized to percentages before interpolation. +The optional third transform origin component must be a length, for example `10px`. All values in one interpolation must have the same type. For each component, units must match. -For missing dimensions, CSS defaults are used: scale defaults to `1`, translate and rotate default to `0`. +For missing dimensions, CSS defaults are used: scale defaults to `1`, translate and rotate default to `0`, and transform origin defaults to `50% 50% 0`. ## Example: Numeric tuples diff --git a/packages/docs/docs/transforms.mdx b/packages/docs/docs/transforms.mdx index 5fb8737c007..f698046ab4e 100644 --- a/packages/docs/docs/transforms.mdx +++ b/packages/docs/docs/transforms.mdx @@ -97,7 +97,7 @@ See the explorer below to see how skewing affects an element. Translating an element means moving it. A translation can be done on the X, Y or even Z axis. The transformation can be specified in `px`. -You can set the translation of an element using the `transform` property. +You can set the translation of an element using the `translate` property. ```tsx twoslash {6} title="MyComponent.tsx"
``` @@ -118,7 +118,7 @@ As opposed to changing the position of an element using `margin-top` and `margin By rotating an element, you can make it appear as if it has been turned around its center. The rotation can be specified in `rad` (radians) or `deg` (degrees) and you can rotate an element around the Z axis (the default) but also around the X and Y axis. -You can set the translation of an element using the `transform` property. +You can set the rotation of an element using the `rotate` property. ```tsx twoslash {6} title="MyComponent.tsx"
``` @@ -141,9 +141,9 @@ Note that when rotating SVG elements, the transform origin is the top left corne ## Multiple transformations -Oftentimes, you want to combine multiple transformations. If they use different CSS properties like `transform` and `opacity`, simply specify both properties in the `style` object. +Oftentimes, you want to combine multiple transformations. If they use different CSS properties like `translate`, `scale` and `opacity`, specify them separately in the `style` object. -If both transformations use the `transform` property, specify multiple transformations separated by a space. +This also makes animations easier to edit in Remotion Studio. ```tsx twoslash {6} title="MyComponent.tsx"
``` -Note that the order matters. The transformations are applied in the order they are specified. +Use `transform` strings when individual CSS transform properties do not cover the effect, such as `skew()`, `perspective()` or order-sensitive transform chains. ## Using the `makeTransform()` helper -Install [`@remotion/animation-utils`](/docs/animation-utils/) to [get a type-safe helper function](/docs/animation-utils/make-transform) to generate `transform` strings. +When you need a `transform` string, install [`@remotion/animation-utils`](/docs/animation-utils/) to [get a type-safe helper function](/docs/animation-utils/make-transform) to generate it. ```tsx twoslash import {makeTransform, rotate, translate} from '@remotion/animation-utils'; diff --git a/packages/docs/package.json b/packages/docs/package.json index 87c240f81f5..3e5afa27547 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -10,7 +10,7 @@ "format": "oxfmt src", "docusaurus": "docusaurus", "start": "bun copy-raw-docs.ts && bun fetch-prompt-submissions.ts && bun update-prompt.ts && cd .. && bun run build && cd docs && docusaurus start --host 0.0.0.0", - "build-docs": "bun copy-raw-docs.ts && bun fetch-prompt-submissions.ts && bun prewarm-twoslash.ts && DOCUSAURUS_IGNORE_SSG_WARNINGS=true docusaurus build && bun copy-convert.ts && bun count-pages.ts", + "build-docs": "bun copy-raw-docs.ts && bun fetch-prompt-submissions.ts && bun prewarm-twoslash.ts && NODE_OPTIONS=\"--max-old-space-size=1024\" DOCUSAURUS_IGNORE_SSG_WARNINGS=true docusaurus build && bun copy-convert.ts && bun count-pages.ts", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "serve": "docusaurus serve", diff --git a/packages/skills/skills/remotion/SKILL.md b/packages/skills/skills/remotion/SKILL.md index 7798833ab08..9e2953ac573 100644 --- a/packages/skills/skills/remotion/SKILL.md +++ b/packages/skills/skills/remotion/SKILL.md @@ -23,10 +23,12 @@ Replace `my-video` with a suitable project name. Before designing visual scenes, layouts, promos, motion graphics, or text-heavy videos, load [rules/video-layout.md](rules/video-layout.md) for video-first layout and text sizing guidance. -Animate properties using `useCurrentFrame()` and `interpolate()`. Use Easing to customize the timing of the animation. +Animate properties using `useCurrentFrame()` and `interpolate()`. Prefer `interpolate()` over `spring()` unless physics-based motion is explicitly needed. Use `Easing.bezier()` to customize timing, including jumpy or overshooting motion. + +For animations that should be editable in Remotion Studio, keep the `interpolate()` call inline in the `style` prop and use individual CSS transform properties (`scale`, `translate`, `rotate`) instead of composing a `transform` string. ```tsx -import { useCurrentFrame, Easing } from "remotion"; +import { useCurrentFrame, Easing, interpolate, useVideoConfig } from "remotion"; export const FadeIn = () => { const frame = useCurrentFrame(); @@ -42,6 +44,26 @@ export const FadeIn = () => { }; ``` +Prefer: + +```tsx +style={{ + scale: interpolate(frame, [0, 100], [0, 1]), + translate: interpolate(frame, [0, 100], ["0px 0px", "100px 100px"]), + rotate: interpolate(frame, [0, 100], ["20deg", "90deg"]), +}} +``` + +Over: + +```tsx +const scale = interpolate(frame, [0, 100], [0, 1]); + +style={{ + transform: `scale(${scale})`, +}} +``` + CSS transitions or animations are FORBIDDEN - they will not render correctly. Tailwind animation class names are FORBIDDEN - they will not render correctly. diff --git a/packages/skills/skills/remotion/rules/timing.md b/packages/skills/skills/remotion/rules/timing.md index b4a3231251f..887e939a9d4 100644 --- a/packages/skills/skills/remotion/rules/timing.md +++ b/packages/skills/skills/remotion/rules/timing.md @@ -5,7 +5,7 @@ metadata: tags: easing, bezier, interpolation, spring, timing --- -Drive motion with `interpolate()` over explicit frame range. To customize timing, use **`Easing.bezier`**. The four parameters are the same as CSS `cubic-bezier(x1, y1, x2, y2)`. +Drive motion with `interpolate()` over an explicit frame range. Prefer `interpolate()` over `spring()` unless the user explicitly asks for physics-based motion. To customize timing, use **`Easing.bezier`**. The four parameters are the same as CSS `cubic-bezier(x1, y1, x2, y2)`. A simple linear interpolation is done using the `interpolate` function. @@ -25,6 +25,30 @@ const opacity = interpolate(frame, [0, 100], [0, 1], { }); ``` +## Studio-editable animation patterns + +When an animation should be editable in Remotion Studio, keep the `interpolate()` call directly in the `style` prop and prefer individual CSS transform properties: + +```tsx +style={{ + scale: interpolate(frame, [0, 100], [0, 1]), + translate: interpolate(frame, [0, 100], ["0px 0px", "100px 100px"]), + rotate: interpolate(frame, [0, 100], ["20deg", "90deg"]), +}} +``` + +Avoid extracting the value and composing a `transform` string: + +```tsx +const scale = interpolate(frame, [0, 100], [0, 1]); + +style={{ + transform: `scale(${scale})`, +}} +``` + +Use `transform` strings only when individual CSS transform properties do not cover the effect, such as `skew()`, `perspective()`, or order-sensitive multi-transform chains. + ## Bézier easing Use `Easing.bezier(x1, y1, x2, y2)` inside the `interpolate` options object. The curve is identical in spirit to CSS animations and transitions, which helps when you are stealing timing from the web or from a designer’s spec. @@ -106,7 +130,7 @@ Use `Easing.out` for enter animations (starts fast, decelerates into place) and ## Composing interpolations -When multiple properties share the same timing (e.g. a slide-in panel and a video shift), avoid duplicating the full interpolation for each property. Instead, create a single normalized progress value (0 to 1) and derive each property from it: +When multiple properties share the same timing and do not need Studio keyframe editing (e.g. a slide-in panel and a video shift), avoid duplicating the full interpolation for each property. Instead, create a single normalized progress value (0 to 1) and derive each property from it: ```tsx const slideIn = interpolate( @@ -134,3 +158,5 @@ const opacity = interpolate(progress, [0, 1], [0, 1]); ``` The key idea: separate **timing** (when and how fast) from **mapping** (what values to animate between). + +If the values should be visually keyframed in Studio, prefer inline `interpolate()` calls in the relevant style props, even if it duplicates the timing. diff --git a/packages/studio-server/src/codemods/update-keyframes/update-keyframes.ts b/packages/studio-server/src/codemods/update-keyframes/update-keyframes.ts index e6ae4c02eb8..a31785c55dc 100644 --- a/packages/studio-server/src/codemods/update-keyframes/update-keyframes.ts +++ b/packages/studio-server/src/codemods/update-keyframes/update-keyframes.ts @@ -48,6 +48,89 @@ import { const b = recast.types.builders; +const getObjectPropertyNameFromUpdateKey = (key: string): string => { + const dotIndex = key.indexOf('.'); + return dotIndex === -1 ? key : key.slice(dotIndex + 1); +}; + +const lineStartsWithPropertyName = ({ + line, + propertyName, +}: { + line: string; + propertyName: string; +}) => { + const trimmed = line.trimStart(); + return ( + trimmed.startsWith(`${propertyName}:`) || + trimmed.startsWith(`'${propertyName}':`) || + trimmed.startsWith(`"${propertyName}":`) + ); +}; + +const getIndent = (line: string) => line.match(/^[ \t]*/)?.[0] ?? ''; + +const removeBlankLinesFromObjectsWithProperties = ({ + input, + propertyNames, +}: { + input: string; + propertyNames: string[]; +}) => { + if (propertyNames.length === 0) { + return input; + } + + const lines = input.split('\n'); + const linesToRemove = new Set(); + + for (let i = 0; i < lines.length; i++) { + const propertyName = propertyNames.find((name) => + lineStartsWithPropertyName({line: lines[i], propertyName: name}), + ); + if (!propertyName) { + continue; + } + + const propertyIndentLength = getIndent(lines[i]).length; + let objectStart = i; + for (let j = i - 1; j >= 0; j--) { + const trimmed = lines[j].trim(); + if ( + trimmed.endsWith('{') && + getIndent(lines[j]).length < propertyIndentLength + ) { + objectStart = j; + break; + } + } + + let objectEnd = i; + for (let j = i + 1; j < lines.length; j++) { + const trimmed = lines[j].trim(); + if ( + trimmed.startsWith('}') && + getIndent(lines[j]).length < propertyIndentLength + ) { + objectEnd = j; + break; + } + } + + for (let j = objectStart + 1; j < objectEnd; j++) { + if (lines[j].trim() === '') { + linesToRemove.add(j); + } + } + } + + if (linesToRemove.size === 0) { + return input; + } + + return lines.filter((_, index) => !linesToRemove.has(index)).join('\n'); +}; + type KeyframeEasing = Extract< CanUpdateSequencePropStatus, {status: 'keyframed'} @@ -1448,9 +1531,16 @@ export const updateSequenceKeyframes = async ({ input: serialized, prettierConfigOverride, }); + const outputWithoutInsertedBlankLines = + removeBlankLinesFromObjectsWithProperties({ + input: output, + propertyNames: updates.map((update) => + getObjectPropertyNameFromUpdateKey(update.key), + ), + }); return { - output, + output: outputWithoutInsertedBlankLines, formatted, oldValueStrings, newValueStrings, @@ -1608,9 +1698,14 @@ export const updateEffectKeyframes = async ({ input: serialized, prettierConfigOverride, }); + const outputWithoutInsertedBlankLines = + removeBlankLinesFromObjectsWithProperties({ + input: output, + propertyNames: updates.map((update) => update.key), + }); return { - output, + output: outputWithoutInsertedBlankLines, formatted, oldValueStrings, newValueStrings, diff --git a/packages/studio-server/src/preview-server/routes/log-updates/format-prop-change.ts b/packages/studio-server/src/preview-server/routes/log-updates/format-prop-change.ts index 2e9040106b2..091f92b4d1d 100644 --- a/packages/studio-server/src/preview-server/routes/log-updates/format-prop-change.ts +++ b/packages/studio-server/src/preview-server/routes/log-updates/format-prop-change.ts @@ -2,7 +2,7 @@ import {formatSideProps} from './format-side-props'; import { formatAddition, formatDeletion, - formatPropDelta, + formatPropChangeDelta, type PropDelta, } from './formatting'; @@ -25,7 +25,7 @@ const formatInnerPropChange = ({ return formatAddition({valueString: newValueString, key}); } - return `${formatPropDelta({valueString: oldValueString, key})} \u2192 ${formatPropDelta({valueString: newValueString, key})}`; + return formatPropChangeDelta({key, oldValueString, newValueString}); }; export const formatPropChange = ({ diff --git a/packages/studio-server/src/preview-server/routes/log-updates/formatting.ts b/packages/studio-server/src/preview-server/routes/log-updates/formatting.ts index 030a9689785..a7ed84be753 100644 --- a/packages/studio-server/src/preview-server/routes/log-updates/formatting.ts +++ b/packages/studio-server/src/preview-server/routes/log-updates/formatting.ts @@ -60,6 +60,49 @@ const formatNestedProp = ( return `${attrName(parentKey)}${equals('=')}${punctuation('{{')}${punctuation(childKey)}${punctuation(':')} ${colorValue(value)}${punctuation('}}')}`; }; +const formatValueDelta = ({ + oldValueString, + newValueString, +}: { + oldValueString: string; + newValueString: string; +}) => { + const withoutUnchangedOptions = shortenUnchangedInterpolateOptions({ + oldValueString, + newValueString, + }); + const shortened = removeUnchangedInterpolateOptionProperties( + withoutUnchangedOptions, + ); + + return `${colorValue(shortened.oldValueString)} ${punctuation('→')} ${colorValue(shortened.newValueString)}`; +}; + +const formatSimplePropChange = ({ + key, + oldValueString, + newValueString, +}: { + key: string; + oldValueString: string; + newValueString: string; +}) => { + return `${attrName(key)}${equals('=')}${punctuation('{')}${formatValueDelta({oldValueString, newValueString})}${punctuation('}')}`; +}; + +const formatNestedPropChange = ({ + key, + oldValueString, + newValueString, +}: { + key: string; + oldValueString: string; + newValueString: string; +}) => { + const dotIdx = key.indexOf('.'); + return `${attrName(key.slice(0, dotIdx))}${equals('=')}${punctuation('{{')}${punctuation(key.slice(dotIdx + 1))}${punctuation(':')} ${formatValueDelta({oldValueString, newValueString})}${punctuation('}}')}`; +}; + export const formatPropDelta = ({key, valueString}: PropDelta) => { const dotIdx = key.indexOf('.'); if (dotIdx === -1) { @@ -73,6 +116,23 @@ export const formatPropDelta = ({key, valueString}: PropDelta) => { ); }; +export const formatPropChangeDelta = ({ + key, + oldValueString, + newValueString, +}: { + key: string; + oldValueString: string; + newValueString: string; +}) => { + const dotIdx = key.indexOf('.'); + if (dotIdx === -1) { + return formatSimplePropChange({key, oldValueString, newValueString}); + } + + return formatNestedPropChange({key, oldValueString, newValueString}); +}; + export const formatDeletion = (prop: PropDelta) => { const formatted = formatPropDelta(prop); return strikeThroughOrRemovedPrefix(formatted); @@ -82,3 +142,271 @@ export const formatAddition = (prop: PropDelta) => { const formatted = formatPropDelta(prop); return addedPrefixIfNoColor(formatted); }; + +const callStart = 'interpolate('; + +type InterpolateCall = { + args: string[]; +}; + +const normalizeArg = (arg: string) => { + return arg + .replace(/\s+/g, ' ') + .replace(/,(\s*[}\]])/g, '$1') + .trim(); +}; + +const shortenUnchangedOptionProperties = new Set([ + 'extrapolateLeft', + 'extrapolateRight', +]); + +const splitTopLevelArgs = (argsSource: string) => { + const args: string[] = []; + let depth = 0; + let quote: "'" | '"' | '`' | null = null; + let start = 0; + + for (let i = 0; i < argsSource.length; i++) { + const char = argsSource[i]; + const previous = argsSource[i - 1]; + + if (quote) { + if (char === quote && previous !== '\\') { + quote = null; + } + + continue; + } + + if (char === "'" || char === '"' || char === '`') { + quote = char; + continue; + } + + if (char === '(' || char === '[' || char === '{') { + depth++; + continue; + } + + if (char === ')' || char === ']' || char === '}') { + depth--; + continue; + } + + if (char === ',' && depth === 0) { + args.push(argsSource.slice(start, i).trim()); + start = i + 1; + } + } + + args.push(argsSource.slice(start).trim()); + return args; +}; + +const getObjectPropertyKey = (propertySource: string): string | null => { + const colonIndex = propertySource.indexOf(':'); + if (colonIndex === -1) { + return null; + } + + const key = propertySource.slice(0, colonIndex).trim(); + if (/^[A-Za-z_$][\w$]*$/.test(key)) { + return key; + } + + if ( + (key.startsWith("'") && key.endsWith("'")) || + (key.startsWith('"') && key.endsWith('"')) + ) { + return key.slice(1, -1); + } + + return null; +}; + +const parseTopLevelObjectProperties = (objectSource: string) => { + const trimmed = objectSource.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return null; + } + + const properties = splitTopLevelArgs(trimmed.slice(1, -1)) + .filter(Boolean) + .map((propertySource) => { + const key = getObjectPropertyKey(propertySource); + if (key === null) { + return null; + } + + const colonIndex = propertySource.indexOf(':'); + return { + key, + value: normalizeArg(propertySource.slice(colonIndex + 1)), + source: propertySource.trim(), + }; + }); + + if (properties.some((property) => property === null)) { + return null; + } + + return properties as { + key: string; + value: string; + source: string; + }[]; +}; + +const parseInterpolateCall = (valueString: string): InterpolateCall | null => { + const trimmed = valueString.trim(); + if (!trimmed.startsWith(callStart) || !trimmed.endsWith(')')) { + return null; + } + + let depth = 0; + let quote: "'" | '"' | '`' | null = null; + + for (let i = 'interpolate'.length; i < trimmed.length; i++) { + const char = trimmed[i]; + const previous = trimmed[i - 1]; + + if (quote) { + if (char === quote && previous !== '\\') { + quote = null; + } + + continue; + } + + if (char === "'" || char === '"' || char === '`') { + quote = char; + continue; + } + + if (char === '(' || char === '[' || char === '{') { + depth++; + continue; + } + + if (char === ')' || char === ']' || char === '}') { + depth--; + if (depth === 0 && i !== trimmed.length - 1) { + return null; + } + } + } + + if (depth !== 0) { + return null; + } + + return { + args: splitTopLevelArgs(trimmed.slice(callStart.length, -1)), + }; +}; + +const shortenUnchangedInterpolateOptions = ({ + oldValueString, + newValueString, +}: { + oldValueString: string; + newValueString: string; +}) => { + const oldCall = parseInterpolateCall(oldValueString); + const newCall = parseInterpolateCall(newValueString); + + if ( + !oldCall || + !newCall || + oldCall.args.length <= 3 || + newCall.args.length <= 3 + ) { + return {oldValueString, newValueString}; + } + + const oldOptions = oldCall.args.slice(3).map(normalizeArg); + const newOptions = newCall.args.slice(3).map(normalizeArg); + if ( + oldOptions.length !== newOptions.length || + oldOptions.some((option, index) => option !== newOptions[index]) + ) { + return {oldValueString, newValueString}; + } + + return { + oldValueString: `interpolate(${oldCall.args.slice(0, 3).join(', ')})`, + newValueString: `interpolate(${newCall.args.slice(0, 3).join(', ')})`, + }; +}; + +const removeUnchangedInterpolateOptionProperties = ({ + oldValueString, + newValueString, +}: { + oldValueString: string; + newValueString: string; +}) => { + const oldCall = parseInterpolateCall(oldValueString); + const newCall = parseInterpolateCall(newValueString); + + if ( + !oldCall || + !newCall || + oldCall.args.length <= 3 || + newCall.args.length <= 3 + ) { + return {oldValueString, newValueString}; + } + + const oldProperties = parseTopLevelObjectProperties(oldCall.args[3]); + const newProperties = parseTopLevelObjectProperties(newCall.args[3]); + if (!oldProperties || !newProperties) { + return {oldValueString, newValueString}; + } + + const propertiesToRemove = [...shortenUnchangedOptionProperties].filter( + (propertyName) => { + const oldProperty = oldProperties.find( + (property) => property.key === propertyName, + ); + const newProperty = newProperties.find( + (property) => property.key === propertyName, + ); + + return ( + oldProperty !== undefined && + newProperty !== undefined && + oldProperty.value === newProperty.value + ); + }, + ); + + if (propertiesToRemove.length === 0) { + return {oldValueString, newValueString}; + } + + const removeProperties = ( + call: InterpolateCall, + properties: typeof oldProperties, + ) => { + const nextOptions = properties.filter( + (property) => !propertiesToRemove.includes(property.key), + ); + const nextArgs = + nextOptions.length === 0 + ? [...call.args.slice(0, 3), ...call.args.slice(4)] + : [ + ...call.args.slice(0, 3), + `{${nextOptions.map((property) => property.source).join(', ')}}`, + ...call.args.slice(4), + ]; + + return `interpolate(${nextArgs.join(', ')})`; + }; + + return { + oldValueString: removeProperties(oldCall, oldProperties), + newValueString: removeProperties(newCall, newProperties), + }; +}; diff --git a/packages/studio-server/src/test/log-update.test.ts b/packages/studio-server/src/test/log-update.test.ts index 03c5d03ebc9..5d6f11a6cb2 100644 --- a/packages/studio-server/src/test/log-update.test.ts +++ b/packages/studio-server/src/test/log-update.test.ts @@ -81,9 +81,12 @@ test('logUpdate emits Monokai-colored output after an AST update', async () => { const logged = consoleSpy.mock.calls[0].join(' '); const simpleProp = (key: string, value: string) => - `${attrName(key)}${equals('=')}${punctuation('{')}${numberValue(value)}${punctuation('}')}`; + `${attrName(key)}${equals('=')}${punctuation('{')}${value}${punctuation('}')}`; - const expectedPropChange = `${simpleProp('hueShift', '30')} → ${simpleProp('hueShift', '90')}`; + const expectedPropChange = simpleProp( + 'hueShift', + `${numberValue('30')} → ${numberValue('90')}`, + ); const expectedLine = `${chalk.blueBright('src/Example.tsx:8')} ${expectedPropChange}`; expect(logged).toBe(expectedLine); @@ -92,6 +95,59 @@ test('logUpdate emits Monokai-colored output after an AST update', async () => { } }); +test('formatPropChange condenses unchanged interpolate options', () => { + const formatted = formatPropChange({ + key: 'width', + oldValueString: `interpolate(frame, [78], [244], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', +})`, + newValueString: `interpolate(frame, [29, 78], [88, 244], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', +})`, + defaultValueString: null, + removedProps: [], + addedProps: [], + }); + + expect(formatted).toBe( + `${attrName('width')}${equals('=')}${punctuation('{')}interpolate(frame, [78], [244]) → interpolate(frame, [29, 78], [88, 244])${punctuation('}')}`, + ); + expect(formatted).not.toContain('extrapolateLeft'); + expect(formatted).not.toContain('extrapolateRight'); +}); + +test('formatPropChange omits unchanged interpolate clamping when easing changes', () => { + const formatted = formatPropChange({ + key: 'scale', + oldValueString: `interpolate(frame, [68, 78, 88], [0, 1, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: [ + Easing.bezier(0.5526, 3.9109, 0.6487, 4.8024), + Easing.bezier(0.5526, 3.9109, 0.995, 5), + ], +})`, + newValueString: `interpolate(frame, [68, 78, 88], [0, 1, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: [ + Easing.bezier(0.5526, 3.9109, 0.6487, 4.8024), + Easing.bezier(0.5526, 3.9109, 0.6487, 4.8024), + ], +})`, + defaultValueString: null, + removedProps: [], + addedProps: [], + }); + + expect(formatted).not.toContain('extrapolateLeft'); + expect(formatted).not.toContain('extrapolateRight'); + expect(formatted).toContain('Easing.bezier(0.5526, 3.9109, 0.995, 5)'); + expect(formatted).toContain('Easing.bezier(0.5526, 3.9109, 0.6487, 4.8024)'); +}); + test('logUpdate emits change-from-default output for discriminated union enum change', async () => { const fixture = readFileSync( path.join(__dirname, 'snapshots', 'discriminated-union-with-style.tsx'), diff --git a/packages/studio-server/src/test/update-keyframes.test.ts b/packages/studio-server/src/test/update-keyframes.test.ts index 5bba4768b88..cf6077c536e 100644 --- a/packages/studio-server/src/test/update-keyframes.test.ts +++ b/packages/studio-server/src/test/update-keyframes.test.ts @@ -404,6 +404,67 @@ test('updateSequenceKeyframes converts a static value to an interpolation', asyn expect(output).toContain("extrapolateRight: 'clamp'"); }); +test('updateSequenceKeyframes does not preserve blank lines around keyframed object properties', async () => { + const input = `import React from 'react'; +import {AbsoluteFill} from 'remotion'; + +export const Example: React.FC = () => { +\treturn ( +\t\t +\t\t\t
+\t\t +\t); +}; +`; + const schema = { + 'style.translate': { + type: 'translate', + default: '0px 0px', + }, + 'style.scale': { + type: 'number', + default: 1, + hiddenFromList: false, + }, + } satisfies SequenceSchema; + const {output} = await updateSequenceKeyframes({ + input, + nodePath: lineColumnToNodePath(input, getLine(input, ' { const schema = { 'style.opacity': { diff --git a/packages/studio/src/components/CompositionSelectorItem.tsx b/packages/studio/src/components/CompositionSelectorItem.tsx index 42306562d5e..03b364ade43 100644 --- a/packages/studio/src/components/CompositionSelectorItem.tsx +++ b/packages/studio/src/components/CompositionSelectorItem.tsx @@ -154,7 +154,7 @@ export const CompositionSelectorItem: React.FC<{ }, [hovered, selected]); const onClick = useCallback( - (evt: MouseEvent | KeyboardEvent) => { + (evt: MouseEvent | KeyboardEvent) => { evt.preventDefault(); if (item.type === 'composition') { markCompositionSidebarScrollFromRowClick(item.composition.id); @@ -166,9 +166,9 @@ export const CompositionSelectorItem: React.FC<{ [item, selectComposition, toggleFolder], ); - const onKeyPress = useCallback( - (evt: React.KeyboardEvent) => { - if (evt.key === 'Enter') { + const onKeyDown = useCallback( + (evt: React.KeyboardEvent) => { + if (evt.key === 'Enter' || evt.key === ' ') { onClick(evt); } }, @@ -209,14 +209,15 @@ export const CompositionSelectorItem: React.FC<{ <> - +
{item.expanded @@ -268,7 +269,7 @@ export const CompositionSelectorItem: React.FC<{ onPointerLeave={onPointerLeave} tabIndex={tabIndex} onClick={onClick} - onKeyPress={onKeyPress} + onKeyDown={onKeyDown} type="button" title={item.composition.id} className="__remotion-composition" diff --git a/packages/studio/src/components/Modals.tsx b/packages/studio/src/components/Modals.tsx index 99cc212ad16..a3471944937 100644 --- a/packages/studio/src/components/Modals.tsx +++ b/packages/studio/src/components/Modals.tsx @@ -15,6 +15,7 @@ import QuickSwitcher from './QuickSwitcher/QuickSwitcher'; import {RenderStatusModal} from './RenderModal/RenderStatusModal'; import {RenderModalWithLoader} from './RenderModal/ServerRenderModal'; import {WebRenderModalWithLoader} from './RenderModal/WebRenderModal'; +import {EasingEditorModal} from './Timeline/EasingEditorModal'; import {KeyframeSettingsModal} from './Timeline/KeyframeSettingsModal'; import {UpdateModal} from './UpdateModal/UpdateModal'; @@ -63,6 +64,9 @@ export const Modals: React.FC<{ {modalContextType && modalContextType.type === 'keyframe-settings' && ( )} + {modalContextType && modalContextType.type === 'easing-editor' && ( + + )} {modalContextType && modalContextType.type === 'web-render' && ( diff --git a/packages/studio/src/components/SelectedOutlineOverlay.tsx b/packages/studio/src/components/SelectedOutlineOverlay.tsx index 31b16cb3343..d1a188969be 100644 --- a/packages/studio/src/components/SelectedOutlineOverlay.tsx +++ b/packages/studio/src/components/SelectedOutlineOverlay.tsx @@ -3,9 +3,7 @@ import type { CanUpdateSequencePropStatusKeyframed, CanUpdateSequencePropStatusStatic, GetDragOverrides, - GetEffectDragOverrides, OverrideIdToNodePaths, - PropStatuses, ResolvedStackLocation, SequenceFieldSchema, SequencePropsSubscriptionKey, @@ -31,8 +29,21 @@ import { } from './effect-drag-and-drop'; import type {ComboboxValue} from './NewComposition/ComboBox'; import {showNotification} from './Notifications/NotificationCenter'; +import { + clamp, + mixPoint, + type OutlinePoint, + type SelectedOutline, +} from './selected-outline-geometry'; +import { + getSelectedUvHandles, + type SelectedOutlineUvHandle, +} from './selected-outline-uv'; +import { + SelectedOutlineUvHandleCircleLayer, + SelectedOutlineUvHandleConnectionLayer, +} from './SelectedOutlineUvControls'; import {callAddSequenceKeyframe} from './Timeline/call-add-keyframe'; -import {saveEffectProp} from './Timeline/save-effect-prop'; import { saveSequenceProps, type SaveSequencePropChange, @@ -45,57 +56,12 @@ import {getLinkedScale} from './Timeline/TimelineScaleField'; import { ENABLE_OUTLINES, getTimelineSequenceSelectionKey, - useTimelineSelection, type TimelineSelection, type TimelineSelectionInteraction, + useTimelineSelection, } from './Timeline/TimelineSelection'; import {getOriginalLocationFromStack} from './Timeline/TimelineStack/get-stack'; -type OutlinePoint = { - readonly x: number; - readonly y: number; -}; - -type SelectedOutline = { - readonly key: string; - readonly points: readonly [ - OutlinePoint, - OutlinePoint, - OutlinePoint, - OutlinePoint, - ]; -}; - -export type UvCoordinate = readonly [number, number]; - -export type UvCoordinateFieldSchema = Extract< - SequenceFieldSchema, - {type: 'uv-coordinate'} ->; - -type SelectedOutlineUvHandle = { - readonly clientId: string; - readonly propStatus: CanUpdateSequencePropStatusStatic; - readonly effectIndex: number; - readonly fieldDefault: UvCoordinate | undefined; - readonly fieldKey: string; - readonly fieldSchema: UvCoordinateFieldSchema; - readonly nodePath: SequencePropsSubscriptionKey; - readonly schema: SequenceSchema; - readonly value: UvCoordinate; -}; - -type UvConnectionHandle = Pick< - SelectedOutlineUvHandle, - 'effectIndex' | 'fieldKey' | 'fieldSchema' | 'value' ->; - -type UvHandleConnectionLine = { - readonly key: string; - readonly from: OutlinePoint; - readonly to: OutlinePoint; -}; - type SelectedOutlineContextMenuOpenResult = | false | void @@ -188,41 +154,6 @@ const emptyContextMenuValues: readonly ComboboxValue[] = []; const pointToString = (point: OutlinePoint) => `${point.x},${point.y}`; -const parseUvCoordinate = (value: unknown): UvCoordinate | null => { - if ( - Array.isArray(value) && - value.length === 2 && - value.every((item) => typeof item === 'number' && Number.isFinite(item)) - ) { - return [value[0], value[1]]; - } - - return null; -}; - -const tuplesEqual = (left: unknown, right: UvCoordinate): boolean => { - if (!Array.isArray(left) || left.length !== 2) { - return false; - } - - return left[0] === right[0] && left[1] === right[1]; -}; - -const mix = (from: number, to: number, progress: number): number => { - return from + (to - from) * progress; -}; - -const mixPoint = ( - from: OutlinePoint, - to: OutlinePoint, - progress: number, -): OutlinePoint => { - return { - x: mix(from.x, to.x, progress), - y: mix(from.y, to.y, progress), - }; -}; - const midpoint = (from: OutlinePoint, to: OutlinePoint): OutlinePoint => { return mixPoint(from, to, 0.5); }; @@ -235,221 +166,10 @@ const vectorLength = (vector: OutlinePoint): number => { return Math.hypot(vector.x, vector.y); }; -const getBilinearUvHandlePosition = ( - points: SelectedOutline['points'], - uv: UvCoordinate, -): OutlinePoint => { - const [tl, tr, br, bl] = points; - const top = mixPoint(tl, tr, uv[0]); - const bottom = mixPoint(bl, br, uv[0]); - return mixPoint(top, bottom, uv[1]); -}; - -type ProjectiveTransform = { - readonly a: number; - readonly b: number; - readonly c: number; - readonly d: number; - readonly e: number; - readonly f: number; - readonly g: number; - readonly h: number; -}; - -const projectiveEpsilon = 0.000001; - -const getProjectiveTransform = ( - points: SelectedOutline['points'], -): ProjectiveTransform | null => { - const [tl, tr, br, bl] = points; - const dx1 = tr.x - br.x; - const dx2 = bl.x - br.x; - const dx3 = tl.x - tr.x + br.x - bl.x; - const dy1 = tr.y - br.y; - const dy2 = bl.y - br.y; - const dy3 = tl.y - tr.y + br.y - bl.y; - - let g = 0; - let h = 0; - if (Math.abs(dx3) > projectiveEpsilon || Math.abs(dy3) > projectiveEpsilon) { - const determinant = dx1 * dy2 - dx2 * dy1; - if (Math.abs(determinant) < projectiveEpsilon) { - return null; - } - - g = (dx3 * dy2 - dx2 * dy3) / determinant; - h = (dx1 * dy3 - dx3 * dy1) / determinant; - } - - return { - a: tr.x - tl.x + g * tr.x, - b: bl.x - tl.x + h * bl.x, - c: tl.x, - d: tr.y - tl.y + g * tr.y, - e: bl.y - tl.y + h * bl.y, - f: tl.y, - g, - h, - }; -}; - -const applyProjectiveTransform = ( - transform: ProjectiveTransform, - uv: UvCoordinate, -): OutlinePoint => { - const denominator = transform.g * uv[0] + transform.h * uv[1] + 1; - return { - x: (transform.a * uv[0] + transform.b * uv[1] + transform.c) / denominator, - y: (transform.d * uv[0] + transform.e * uv[1] + transform.f) / denominator, - }; -}; - -export const getUvHandlePosition = ( - points: SelectedOutline['points'], - uv: UvCoordinate, -): OutlinePoint => { - const transform = getProjectiveTransform(points); - return transform === null - ? getBilinearUvHandlePosition(points, uv) - : applyProjectiveTransform(transform, uv); -}; - -export const getUvHandleConnectionLines = ({ - handles, - points, -}: { - readonly handles: readonly UvConnectionHandle[]; - readonly points: SelectedOutline['points']; -}): UvHandleConnectionLine[] => { - const handlesByField = new Map( - handles.map((handle) => [ - `${handle.effectIndex}\u0000${handle.fieldKey}`, - handle, - ]), - ); - const seenPairs = new Set(); - const lines: UvHandleConnectionLine[] = []; - - for (const handle of handles) { - const targetFieldKey = handle.fieldSchema.lineTo; - if (targetFieldKey === undefined || targetFieldKey === handle.fieldKey) { - continue; - } - - const target = handlesByField.get( - `${handle.effectIndex}\u0000${targetFieldKey}`, - ); - if (target === undefined) { - continue; - } - - const pairKey = [ - handle.effectIndex, - ...[handle.fieldKey, targetFieldKey].sort(), - ].join('\u0000'); - if (seenPairs.has(pairKey)) { - continue; - } - - seenPairs.add(pairKey); - lines.push({ - key: `${handle.effectIndex}-${handle.fieldKey}-${targetFieldKey}`, - from: getUvHandlePosition(points, handle.value), - to: getUvHandlePosition(points, target.value), - }); - } - - return lines; -}; - const vectorBetween = (from: OutlinePoint, to: OutlinePoint): OutlinePoint => { return {x: to.x - from.x, y: to.y - from.y}; }; -const getBilinearUvCoordinateForPoint = ( - points: SelectedOutline['points'], - point: OutlinePoint, -): UvCoordinate => { - const [tl, tr, br, bl] = points; - let u = 0.5; - let v = 0.5; - - for (let i = 0; i < 8; i++) { - const current = getBilinearUvHandlePosition(points, [u, v]); - const errorX = current.x - point.x; - const errorY = current.y - point.y; - if (Math.abs(errorX) + Math.abs(errorY) < 0.001) { - break; - } - - const du = { - x: mix(tr.x - tl.x, br.x - bl.x, v), - y: mix(tr.y - tl.y, br.y - bl.y, v), - }; - const dv = vectorBetween(mixPoint(tl, tr, u), mixPoint(bl, br, u)); - const determinant = du.x * dv.y - du.y * dv.x; - if (Math.abs(determinant) < 0.000001) { - break; - } - - u -= (errorX * dv.y - errorY * dv.x) / determinant; - v -= (du.x * errorY - du.y * errorX) / determinant; - } - - return [u, v]; -}; - -export const getUvCoordinateForPoint = ( - points: SelectedOutline['points'], - point: OutlinePoint, -): UvCoordinate => { - const transform = getProjectiveTransform(points); - if (transform === null) { - return getBilinearUvCoordinateForPoint(points, point); - } - - const determinant = - transform.a * (transform.e - transform.f * transform.h) - - transform.b * (transform.d - transform.f * transform.g) + - transform.c * (transform.d * transform.h - transform.e * transform.g); - if (Math.abs(determinant) < projectiveEpsilon) { - return getBilinearUvCoordinateForPoint(points, point); - } - - const inverseA = transform.e - transform.f * transform.h; - const inverseB = transform.c * transform.h - transform.b; - const inverseC = transform.b * transform.f - transform.c * transform.e; - const inverseD = transform.f * transform.g - transform.d; - const inverseE = transform.a - transform.c * transform.g; - const inverseF = transform.c * transform.d - transform.a * transform.f; - const inverseG = transform.d * transform.h - transform.e * transform.g; - const inverseH = transform.b * transform.g - transform.a * transform.h; - const inverseI = transform.a * transform.e - transform.b * transform.d; - - const denominator = inverseG * point.x + inverseH * point.y + inverseI; - if (Math.abs(denominator) < projectiveEpsilon) { - return getBilinearUvCoordinateForPoint(points, point); - } - - return [ - (inverseA * point.x + inverseB * point.y + inverseC) / denominator, - (inverseD * point.x + inverseE * point.y + inverseF) / denominator, - ]; -}; - -const clamp = (value: number, min: number, max: number): number => { - return Math.min(max, Math.max(min, value)); -}; - -export function constrainUv( - value: UvCoordinate, - schema: UvCoordinateFieldSchema, -): UvCoordinate { - const min = schema.min ?? -Infinity; - const max = schema.max ?? Infinity; - return [clamp(value[0], min, max), clamp(value[1], min, max)]; -} - const rectToPoints = ( elementRect: DOMRect, containerRect: DOMRect, @@ -615,101 +335,6 @@ export const getSequencesWithSelectableOutlines = ({ }); }; -const getSelectedUvHandles = ({ - propStatuses, - clientId, - getEffectDragOverrides, - nodePath, - selectedEffects, - sequence, -}: { - readonly propStatuses: PropStatuses; - readonly clientId: string | null; - readonly getEffectDragOverrides: GetEffectDragOverrides; - readonly nodePath: SequencePropsSubscriptionKey; - readonly selectedEffects: Map | undefined; - readonly sequence: TSequence; -}): SelectedOutlineUvHandle[] => { - if (clientId === null || selectedEffects === undefined) { - return []; - } - - const handles: SelectedOutlineUvHandle[] = []; - - for (const [effectIndex, selectedFields] of selectedEffects) { - const effect = sequence.effects[effectIndex]; - if (!effect) { - continue; - } - - const effectStatus = Internals.getEffectPropStatusesCtx({ - propStatuses, - nodePath, - effectIndex, - }); - if (effectStatus.type !== 'can-update-effect') { - continue; - } - - const dragOverrides = getEffectDragOverrides(nodePath, effectIndex); - const activeSchema = Internals.flattenActiveSchema(effect.schema, (key) => { - const dragOverride = Internals.getStaticDragOverrideValue( - dragOverrides[key], - ); - if (dragOverride !== undefined) { - return dragOverride; - } - - const propStatus = effectStatus.props[key]; - if (propStatus?.status !== 'static') { - return undefined; - } - - return propStatus.codeValue; - }); - - for (const [fieldKey, fieldSchema] of Object.entries(activeSchema)) { - if ( - fieldSchema.type !== 'uv-coordinate' || - (!selectedFields.allFields && !selectedFields.fieldKeys.has(fieldKey)) - ) { - continue; - } - - const propStatus = effectStatus.props[fieldKey]; - if (propStatus?.status !== 'static') { - continue; - } - - const dragOverrideValue = dragOverrides[fieldKey]; - const effectiveValue = Internals.getEffectiveVisualModeValue({ - propStatus, - dragOverrideValue, - defaultValue: fieldSchema.default, - shouldResortToDefaultValueIfUndefined: true, - }); - const value = parseUvCoordinate(effectiveValue); - if (value === null) { - continue; - } - - handles.push({ - clientId, - propStatus, - effectIndex, - fieldDefault: fieldSchema.default, - fieldKey, - fieldSchema, - nodePath, - schema: effect.schema, - value, - }); - } - } - - return handles; -}; - const measureOutlines = ( container: SVGSVGElement, targets: readonly SelectedOutlineTarget[], @@ -816,6 +441,26 @@ export const getSelectedOutlineDragValues = ({ ); }; +export const applySelectedOutlineDragAxisLock = ({ + deltaX, + deltaY, + axisLocked, +}: { + readonly deltaX: number; + readonly deltaY: number; + readonly axisLocked: boolean; +}) => { + if (!axisLocked) { + return {deltaX, deltaY}; + } + + if (Math.abs(deltaX) >= Math.abs(deltaY)) { + return {deltaX, deltaY: 0}; + } + + return {deltaX: 0, deltaY}; +}; + export type SelectedOutlineStaticDragChange = SaveSequencePropChange & { readonly type: 'static'; }; @@ -1152,14 +797,21 @@ const SelectedOutlinePolygon: React.FC<{ timelinePosition: timelinePositionRef.current, }); let lastValues = new Map(); - - const onPointerMove = (moveEvent: PointerEvent) => { - moveEvent.preventDefault(); + let currentPointerX = startPointerX; + let currentPointerY = startPointerY; + let axisLocked = false; + + const updateDragOverrides = () => { + const dragDelta = applySelectedOutlineDragAxisLock({ + deltaX: (currentPointerX - startPointerX) / scale, + deltaY: (currentPointerY - startPointerY) / scale, + axisLocked, + }); lastValues = getSelectedOutlineDragValues({ dragStates, - deltaX: (moveEvent.clientX - startPointerX) / scale, - deltaY: (moveEvent.clientY - startPointerY) / scale, + deltaX: dragDelta.deltaX, + deltaY: dragDelta.deltaY, }); for (const dragState of dragStates) { const value = lastValues.get(dragState.key); @@ -1187,10 +839,34 @@ const SelectedOutlinePolygon: React.FC<{ } }; + const onPointerMove = (moveEvent: PointerEvent) => { + moveEvent.preventDefault(); + currentPointerX = moveEvent.clientX; + currentPointerY = moveEvent.clientY; + axisLocked = moveEvent.shiftKey; + updateDragOverrides(); + }; + + const onKeyChange = (keyEvent: KeyboardEvent) => { + if (keyEvent.key !== 'Shift') { + return; + } + + const nextAxisLocked = keyEvent.type === 'keydown'; + if (nextAxisLocked === axisLocked) { + return; + } + + axisLocked = nextAxisLocked; + updateDragOverrides(); + }; + const onPointerUp = () => { window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointercancel', onPointerUp); + window.removeEventListener('keydown', onKeyChange); + window.removeEventListener('keyup', onKeyChange); onDraggingChange(false); const changes = getSelectedOutlineDragChanges({ @@ -1257,6 +933,8 @@ const SelectedOutlinePolygon: React.FC<{ window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); window.addEventListener('pointercancel', onPointerUp); + window.addEventListener('keydown', onKeyChange); + window.addEventListener('keyup', onKeyChange); }, [ allDragTargets, @@ -1564,175 +1242,6 @@ const SelectedOutlineScaleEdgeLine: React.FC<{ ); }; -const getSvgPointFromPointerEvent = ({ - event, - rect, -}: { - readonly event: Pick; - readonly rect: DOMRect; -}): OutlinePoint => { - return { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }; -}; - -const SelectedUvHandleConnectionLines: React.FC<{ - readonly handles: readonly SelectedOutlineUvHandle[]; - readonly outline: SelectedOutline; -}> = ({handles, outline}) => { - const lines = useMemo( - () => getUvHandleConnectionLines({handles, points: outline.points}), - [handles, outline.points], - ); - - return ( - <> - {lines.map((line) => ( - - ))} - - ); -}; - -const SelectedUvHandleCircle: React.FC<{ - readonly onDraggingChange: (dragging: boolean) => void; - readonly handle: SelectedOutlineUvHandle; - readonly outline: SelectedOutline; -}> = ({handle, onDraggingChange, outline}) => { - const {setEffectDragOverrides, clearEffectDragOverrides, setPropStatuses} = - useContext(Internals.VisualModeSettersContext); - const position = useMemo( - () => getUvHandlePosition(outline.points, handle.value), - [handle.value, outline.points], - ); - - const onPointerDown = React.useCallback( - (event: React.PointerEvent) => { - if (event.button !== 0) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const svg = event.currentTarget.ownerSVGElement; - if (svg === null) { - return; - } - - const svgRect = svg.getBoundingClientRect(); - let lastValue: UvCoordinate | null = null; - onDraggingChange(true); - const defaultValue = - handle.fieldDefault !== undefined - ? JSON.stringify(handle.fieldDefault) - : null; - - const updateFromPointerEvent = ( - pointerEvent: PointerEvent | React.PointerEvent, - ) => { - const point = getSvgPointFromPointerEvent({ - event: pointerEvent, - rect: svgRect, - }); - const nextValue = constrainUv( - getUvCoordinateForPoint(outline.points, point), - handle.fieldSchema, - ); - lastValue = nextValue; - setEffectDragOverrides( - handle.nodePath, - handle.effectIndex, - handle.fieldKey, - Internals.makeStaticDragOverride(nextValue), - ); - }; - - updateFromPointerEvent(event); - - const onPointerMove = (moveEvent: PointerEvent) => { - moveEvent.preventDefault(); - updateFromPointerEvent(moveEvent); - }; - - const onPointerUp = () => { - window.removeEventListener('pointermove', onPointerMove); - window.removeEventListener('pointerup', onPointerUp); - window.removeEventListener('pointercancel', onPointerUp); - onDraggingChange(false); - - const stringifiedValue = - lastValue === null ? null : JSON.stringify(lastValue); - const shouldSave = - lastValue !== null && - !tuplesEqual(handle.propStatus.codeValue, lastValue) && - !( - defaultValue === stringifiedValue && - handle.propStatus.codeValue === undefined - ); - - if (!shouldSave) { - clearEffectDragOverrides(handle.nodePath, handle.effectIndex); - return; - } - - saveEffectProp({ - type: 'value', - fileName: handle.nodePath.absolutePath, - nodePath: handle.nodePath, - effectIndex: handle.effectIndex, - fieldKey: handle.fieldKey, - value: lastValue, - defaultValue, - schema: handle.schema, - setPropStatuses, - clientId: handle.clientId, - }).finally(() => { - clearEffectDragOverrides(handle.nodePath, handle.effectIndex); - }); - }; - - window.addEventListener('pointermove', onPointerMove); - window.addEventListener('pointerup', onPointerUp); - window.addEventListener('pointercancel', onPointerUp); - }, - [ - clearEffectDragOverrides, - handle, - onDraggingChange, - outline.points, - setPropStatuses, - setEffectDragOverrides, - ], - ); - - return ( - - ); -}; - const SelectedOutlineElement: React.FC<{ readonly allDragTargets: readonly SelectedOutlineDragTarget[]; readonly allScaleDragTargets: readonly SelectedOutlineScaleDragTarget[]; @@ -1886,45 +1395,6 @@ const SelectedOutlineElement: React.FC<{ ); }; -const SelectedOutlineUvHandleConnectionLayer: React.FC<{ - readonly outline: SelectedOutline; - readonly target: SelectedOutlineTarget | undefined; -}> = ({outline, target}) => { - if (!target?.containsSelection || target.uvHandles.length === 0) { - return null; - } - - return ( - - ); -}; - -const SelectedOutlineUvHandleCircleLayer: React.FC<{ - readonly onDraggingChange: (dragging: boolean) => void; - readonly outline: SelectedOutline; - readonly target: SelectedOutlineTarget | undefined; -}> = ({onDraggingChange, outline, target}) => { - if (!target?.containsSelection || target.uvHandles.length === 0) { - return null; - } - - return ( - <> - {target.uvHandles.map((handle) => ( - - ))} - - ); -}; - export const SelectedOutlineOverlay: React.FC<{ readonly scale: number; }> = ({scale}) => { @@ -1940,6 +1410,7 @@ export const SelectedOutlineOverlay: React.FC<{ ); const {getScaleLockState} = useContext(ScaleLockContext); const {editorShowOutlines} = useContext(EditorShowOutlinesContext); + const timelinePosition = Internals.Timeline.useTimelinePosition(); const [outlines, setOutlines] = useState([]); const [hoveredOutlineKey, setHoveredOutlineKey] = useState( null, @@ -2069,6 +1540,7 @@ export const SelectedOutlineOverlay: React.FC<{ nodePath, selectedEffects: selectedEffectsBySequenceKey.get(key), sequence, + sourceFrame: timelinePosition - keyframeDisplayOffset, }) : [], }; @@ -2083,6 +1555,7 @@ export const SelectedOutlineOverlay: React.FC<{ previewServerState, selectedItems, sequences, + timelinePosition, ]); useEffect(() => { diff --git a/packages/studio/src/components/SelectedOutlineUvControls.tsx b/packages/studio/src/components/SelectedOutlineUvControls.tsx new file mode 100644 index 00000000000..bb33021f97c --- /dev/null +++ b/packages/studio/src/components/SelectedOutlineUvControls.tsx @@ -0,0 +1,257 @@ +import React, {useContext, useMemo} from 'react'; +import {Internals} from 'remotion'; +import {BLUE} from '../helpers/colors'; +import type {OutlinePoint, SelectedOutline} from './selected-outline-geometry'; +import { + constrainUv, + getUvCoordinateForPoint, + getUvHandleConnectionLines, + getUvHandlePosition, + tuplesEqual, + type SelectedOutlineUvHandle, + type UvCoordinate, +} from './selected-outline-uv'; +import {callAddEffectKeyframe} from './Timeline/call-add-keyframe'; +import {saveEffectProp} from './Timeline/save-effect-prop'; + +const getSvgPointFromPointerEvent = ({ + event, + rect, +}: { + readonly event: Pick; + readonly rect: DOMRect; +}): OutlinePoint => { + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; +}; + +const SelectedUvHandleConnectionLines: React.FC<{ + readonly handles: readonly SelectedOutlineUvHandle[]; + readonly outline: SelectedOutline; +}> = ({handles, outline}) => { + const lines = useMemo( + () => getUvHandleConnectionLines({handles, points: outline.points}), + [handles, outline.points], + ); + + return ( + <> + {lines.map((line) => ( + + ))} + + ); +}; + +const SelectedUvHandleCircle: React.FC<{ + readonly onDraggingChange: (dragging: boolean) => void; + readonly handle: SelectedOutlineUvHandle; + readonly outline: SelectedOutline; +}> = ({handle, onDraggingChange, outline}) => { + const {setEffectDragOverrides, clearEffectDragOverrides, setPropStatuses} = + useContext(Internals.VisualModeSettersContext); + const position = useMemo( + () => getUvHandlePosition(outline.points, handle.value), + [handle.value, outline.points], + ); + + const onPointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const svg = event.currentTarget.ownerSVGElement; + if (svg === null) { + return; + } + + const svgRect = svg.getBoundingClientRect(); + let lastValue: UvCoordinate | null = null; + onDraggingChange(true); + const defaultValue = + handle.fieldDefault !== undefined + ? JSON.stringify(handle.fieldDefault) + : null; + + const updateFromPointerEvent = ( + pointerEvent: PointerEvent | React.PointerEvent, + ) => { + const point = getSvgPointFromPointerEvent({ + event: pointerEvent, + rect: svgRect, + }); + const nextValue = constrainUv( + getUvCoordinateForPoint(outline.points, point), + handle.fieldSchema, + ); + lastValue = nextValue; + setEffectDragOverrides( + handle.nodePath, + handle.effectIndex, + handle.fieldKey, + handle.propStatus.status === 'keyframed' + ? Internals.makeKeyframedDragOverride({ + status: handle.propStatus, + frame: handle.sourceFrame, + value: nextValue, + }) + : Internals.makeStaticDragOverride(nextValue), + ); + }; + + updateFromPointerEvent(event); + + const onPointerMove = (moveEvent: PointerEvent) => { + moveEvent.preventDefault(); + updateFromPointerEvent(moveEvent); + }; + + const onPointerUp = () => { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); + onDraggingChange(false); + + const stringifiedValue = + lastValue === null ? null : JSON.stringify(lastValue); + const shouldSave = (() => { + if (lastValue === null) { + return false; + } + + if (handle.propStatus.status === 'keyframed') { + return !tuplesEqual(handle.value, lastValue); + } + + return ( + !tuplesEqual(handle.propStatus.codeValue, lastValue) && + !( + defaultValue === stringifiedValue && + handle.propStatus.codeValue === undefined + ) + ); + })(); + + if (!shouldSave) { + clearEffectDragOverrides(handle.nodePath, handle.effectIndex); + return; + } + + (handle.propStatus.status === 'keyframed' + ? callAddEffectKeyframe({ + fileName: handle.nodePath.absolutePath, + nodePath: handle.nodePath, + effectIndex: handle.effectIndex, + fieldKey: handle.fieldKey, + sourceFrame: handle.sourceFrame, + value: lastValue, + schema: handle.schema, + setPropStatuses, + clientId: handle.clientId, + }) + : saveEffectProp({ + type: 'value', + fileName: handle.nodePath.absolutePath, + nodePath: handle.nodePath, + effectIndex: handle.effectIndex, + fieldKey: handle.fieldKey, + value: lastValue, + defaultValue, + schema: handle.schema, + setPropStatuses, + clientId: handle.clientId, + }) + ).finally(() => { + clearEffectDragOverrides(handle.nodePath, handle.effectIndex); + }); + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerUp); + }, + [ + clearEffectDragOverrides, + handle, + onDraggingChange, + outline.points, + setPropStatuses, + setEffectDragOverrides, + ], + ); + + return ( + + ); +}; + +type UvTarget = { + readonly containsSelection: boolean; + readonly uvHandles: readonly SelectedOutlineUvHandle[]; +}; + +export const SelectedOutlineUvHandleConnectionLayer: React.FC<{ + readonly outline: SelectedOutline; + readonly target: UvTarget | undefined; +}> = ({outline, target}) => { + if (!target?.containsSelection || target.uvHandles.length === 0) { + return null; + } + + return ( + + ); +}; + +export const SelectedOutlineUvHandleCircleLayer: React.FC<{ + readonly onDraggingChange: (dragging: boolean) => void; + readonly outline: SelectedOutline; + readonly target: UvTarget | undefined; +}> = ({onDraggingChange, outline, target}) => { + if (!target?.containsSelection || target.uvHandles.length === 0) { + return null; + } + + return ( + <> + {target.uvHandles.map((handle) => ( + + ))} + + ); +}; diff --git a/packages/studio/src/components/Timeline/EasingEditorModal.tsx b/packages/studio/src/components/Timeline/EasingEditorModal.tsx new file mode 100644 index 00000000000..62adc177e99 --- /dev/null +++ b/packages/studio/src/components/Timeline/EasingEditorModal.tsx @@ -0,0 +1,508 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import {Internals} from 'remotion'; +import {StudioServerConnectionCtx} from '../../helpers/client-id'; +import { + BLUE, + INPUT_BACKGROUND, + INPUT_BORDER_COLOR_HOVERED, + LIGHT_TEXT, +} from '../../helpers/colors'; +import {ModalsContext} from '../../state/modals'; +import {Button} from '../Button'; +import {Row, Spacing} from '../layout'; +import {ModalButton} from '../ModalButton'; +import {ModalFooterContainer} from '../ModalFooter'; +import {ModalHeader} from '../ModalHeader'; +import {DismissableModal} from '../NewComposition/DismissableModal'; +import {InputDragger} from '../NewComposition/InputDragger'; +import type {TimelineSelection} from './TimelineSelection'; +import type {TimelineEasingValue} from './update-selected-easing'; +import {updateSelectedTimelineEasings} from './update-selected-easing'; + +type CubicBezier = [number, number, number, number]; +type HandleIndex = 0 | 1; +type Coordinate = 'x' | 'y'; + +const SVG_WIDTH = 560; +const SVG_HEIGHT = 320; +const PLOT_LEFT = 42; +const PLOT_TOP = 8; +const PLOT_WIDTH = 500; +const PLOT_HEIGHT = 304; +const Y_MIN = -2; +const Y_MAX = 3; +const LINEAR_BEZIER: CubicBezier = [0.25, 0.25, 0.75, 0.75]; + +const container: React.CSSProperties = { + width: 600, +}; + +const coordinatesGrid: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', + gap: 10, + marginTop: 12, + padding: '0 16px 16px', +}; + +const coordinateRow: React.CSSProperties = { + gap: 4, +}; + +const coordinateLabel: React.CSSProperties = { + fontSize: 13, + color: 'rgba(255, 255, 255, 0.72)', + paddingLeft: 6, +}; + +const coordinateInputWrapper: React.CSSProperties = { + height: 34, + display: 'flex', + alignItems: 'center', +}; + +const numberInputStyle: React.CSSProperties = { + backgroundColor: INPUT_BACKGROUND, + borderRadius: 4, + width: '100%', +}; + +const svgStyle: React.CSSProperties = { + display: 'block', + width: '100%', +}; + +const hiddenSubmit: React.CSSProperties = { + display: 'none', +}; + +const clamp = (value: number, min: number, max: number) => { + return Math.min(max, Math.max(min, value)); +}; + +const sanitizeBezier = (bezier: CubicBezier): CubicBezier => [ + clamp(bezier[0], 0, 1), + clamp(bezier[1], Y_MIN, Y_MAX), + clamp(bezier[2], 0, 1), + clamp(bezier[3], Y_MIN, Y_MAX), +]; + +const easingToBezier = (easing: TimelineEasingValue): CubicBezier => { + return easing === 'linear' ? LINEAR_BEZIER : sanitizeBezier(easing); +}; + +const roundCoordinate = (value: number) => Math.round(value * 10000) / 10000; + +const serializeBezier = (bezier: CubicBezier): TimelineEasingValue => { + const rounded = sanitizeBezier(bezier).map(roundCoordinate) as CubicBezier; + if (rounded[0] === rounded[1] && rounded[2] === rounded[3]) { + return 'linear'; + } + + return rounded; +}; + +const formatNumber = (value: number | string) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return String(value); + } + + return String(roundCoordinate(numericValue)); +}; + +const xToSvg = (value: number) => PLOT_LEFT + value * PLOT_WIDTH; +const yToSvg = (value: number) => + PLOT_TOP + ((Y_MAX - value) / (Y_MAX - Y_MIN)) * PLOT_HEIGHT; + +const pointFromBezier = (bezier: CubicBezier, handle: HandleIndex) => { + const x = handle === 0 ? bezier[0] : bezier[2]; + const y = handle === 0 ? bezier[1] : bezier[3]; + return {x: xToSvg(x), y: yToSvg(y)}; +}; + +const startPoint = {x: xToSvg(0), y: yToSvg(0)}; +const endPoint = {x: xToSvg(1), y: yToSvg(1)}; + +export type EasingEditorModalState = { + type: 'easing-editor'; + initialEasing: TimelineEasingValue; + selections: TimelineSelection[]; +}; + +export const EasingEditorModal: React.FC<{ + readonly state: EasingEditorModalState; +}> = ({state}) => { + const {setSelectedModal} = useContext(ModalsContext); + const {previewServerState} = useContext(StudioServerConnectionCtx); + const sequencesRef = useContext(Internals.SequenceManagerRefContext); + const propStatusesRef = useContext( + Internals.VisualModePropStatusesRefContext, + ); + const {setPropStatuses} = useContext(Internals.VisualModeSettersContext); + const {overrideIdToNodePathMappings} = useContext( + Internals.OverrideIdsToNodePathsGettersContext, + ); + const svgRef = useRef(null); + const [bezier, setBezier] = useState(() => + easingToBezier(state.initialEasing), + ); + const [saving, setSaving] = useState(false); + const [activeHandle, setActiveHandle] = useState(null); + + const close = useCallback(() => { + setSelectedModal(null); + }, [setSelectedModal]); + + const setCoordinate = useCallback( + (handle: HandleIndex, coordinate: Coordinate, value: number) => { + setBezier((previous) => { + const next = [...previous] as CubicBezier; + const index = + handle === 0 + ? coordinate === 'x' + ? 0 + : 1 + : coordinate === 'x' + ? 2 + : 3; + next[index] = + coordinate === 'x' ? clamp(value, 0, 1) : clamp(value, Y_MIN, Y_MAX); + return next; + }); + }, + [], + ); + + const getValueFromPointer = useCallback( + (event: {clientX: number; clientY: number}) => { + const svg = svgRef.current; + if (!svg) { + return null; + } + + const rect = svg.getBoundingClientRect(); + const svgX = ((event.clientX - rect.left) / rect.width) * SVG_WIDTH; + const svgY = ((event.clientY - rect.top) / rect.height) * SVG_HEIGHT; + const x = clamp((svgX - PLOT_LEFT) / PLOT_WIDTH, 0, 1); + const y = clamp( + Y_MAX - ((svgY - PLOT_TOP) / PLOT_HEIGHT) * (Y_MAX - Y_MIN), + Y_MIN, + Y_MAX, + ); + + return {x, y}; + }, + [], + ); + + const updateHandleFromPointer = useCallback( + (handle: HandleIndex, event: {clientX: number; clientY: number}) => { + const value = getValueFromPointer(event); + if (!value) { + return; + } + + setBezier((previous) => { + const next = [...previous] as CubicBezier; + if (handle === 0) { + next[0] = value.x; + next[1] = value.y; + } else { + next[2] = value.x; + next[3] = value.y; + } + + return next; + }); + }, + [getValueFromPointer], + ); + + useEffect(() => { + if (activeHandle === null) { + return; + } + + const onPointerMove = (event: PointerEvent) => { + updateHandleFromPointer(activeHandle, event); + }; + + const onPointerUp = () => { + setActiveHandle(null); + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp, {once: true}); + + return () => { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + }; + }, [activeHandle, updateHandleFromPointer]); + + const onHandlePointerDown = useCallback( + (handle: HandleIndex, event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + setActiveHandle(handle); + updateHandleFromPointer(handle, event); + }, + [updateHandleFromPointer], + ); + + const onSave = useCallback(() => { + if (previewServerState.type !== 'connected' || saving) { + return; + } + + setSaving(true); + const promise = updateSelectedTimelineEasings({ + selections: state.selections, + sequences: sequencesRef.current, + overrideIdsToNodePaths: overrideIdToNodePathMappings, + propStatuses: propStatusesRef.current, + setPropStatuses, + clientId: previewServerState.clientId, + easing: serializeBezier(bezier), + }); + + if (promise === null) { + setSaving(false); + return; + } + + promise.catch(() => undefined); + close(); + }, [ + bezier, + close, + overrideIdToNodePathMappings, + previewServerState, + propStatusesRef, + saving, + sequencesRef, + setPropStatuses, + state.selections, + ]); + + const onSubmit: React.FormEventHandler = useCallback( + (event) => { + event.preventDefault(); + onSave(); + }, + [onSave], + ); + + const path = useMemo(() => { + const curveFirstHandle = pointFromBezier(bezier, 0); + const curveSecondHandle = pointFromBezier(bezier, 1); + return `M ${startPoint.x} ${startPoint.y} C ${curveFirstHandle.x} ${curveFirstHandle.y}, ${curveSecondHandle.x} ${curveSecondHandle.y}, ${endPoint.x} ${endPoint.y}`; + }, [bezier]); + + const firstHandle = pointFromBezier(bezier, 0); + const secondHandle = pointFromBezier(bezier, 1); + const yZero = yToSvg(0); + const yOne = yToSvg(1); + const saveDisabled = saving || previewServerState.type !== 'connected'; + + return ( + + +
+
+ + + + + + + + + + onHandlePointerDown(0, event)} + /> + onHandlePointerDown(1, event)} + /> + + 0 + + + 1 + + +
+
+
X1
+
+ setCoordinate(0, 'x', value)} + onValueChangeEnd={(value) => setCoordinate(0, 'x', value)} + onTextChange={() => undefined} + min={0} + max={1} + step={0.01} + formatter={formatNumber} + rightAlign={false} + style={numberInputStyle} + snapToStep={false} + /> +
+
+
+
Y1
+
+ setCoordinate(0, 'y', value)} + onValueChangeEnd={(value) => setCoordinate(0, 'y', value)} + onTextChange={() => undefined} + min={Y_MIN} + max={Y_MAX} + step={0.01} + formatter={formatNumber} + rightAlign={false} + style={numberInputStyle} + snapToStep={false} + /> +
+
+
+
X2
+
+ setCoordinate(1, 'x', value)} + onValueChangeEnd={(value) => setCoordinate(1, 'x', value)} + onTextChange={() => undefined} + min={0} + max={1} + step={0.01} + formatter={formatNumber} + rightAlign={false} + style={numberInputStyle} + snapToStep={false} + /> +
+
+
+
Y2
+
+ setCoordinate(1, 'y', value)} + onValueChangeEnd={(value) => setCoordinate(1, 'y', value)} + onTextChange={() => undefined} + min={Y_MIN} + max={Y_MAX} + step={0.01} + formatter={formatNumber} + rightAlign={false} + style={numberInputStyle} + snapToStep={false} + /> +
+
+
+
+ + + + + + Save + + + +