Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions packages/core/src/interpolate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends readonly number[]> = {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 => {
Expand All @@ -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),
);
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/test/interpolate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down
15 changes: 6 additions & 9 deletions packages/docs/docs/ai/dynamic-compilation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -188,17 +188,10 @@ export function useCompilation(code: string): CompilationResult {
<TabItem value="generated" label="Input code">

```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 (
<AbsoluteFill
Expand All @@ -212,7 +205,11 @@ export const MyComposition = () => {
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
Expand Down
6 changes: 4 additions & 2 deletions packages/docs/docs/ai/generate.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand Down
Loading
Loading