Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 additions & 2 deletions packages/docs/docs/client-side-rendering/limitations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ It is not feasible to support all CSS properties and factors affecting the visua

Properties such as `margin`, `left`, `display`, `width`, `height`, `flex`, `flex-direction` affect the position and size of the element. Those are all <a style={{color: 'green'}}>supported</a> because Remotion uses [`.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) to obtain the position and size of the element.

The `overflow` property is <a style={{color: 'green'}}>supported</a>.
The `overflow` property is <a style={{color: 'green'}}>supported</a>.
The `object-fit` property is <a style={{color: 'green'}}>supported</a>.

### Transformations

Expand Down Expand Up @@ -105,7 +106,6 @@ Here is a selection of unsupported styles:
- `backdrop-filter`
- `mix-blend-mode`
- `background-blend-mode`
- `object-fit`
- `backface-visibility`
- `z-index`
- See [Z-indexing](#z-indexing) below.
Expand Down
3 changes: 2 additions & 1 deletion packages/web-renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"lint": "eslint src",
"make": "tsc -d && bun --env-file=../.env.bundle bundle.ts",
"testwebrenderer": "vitest src/test --browser --run",
"studio": "cd ../example && bunx remotion studio ../web-renderer/src/test/studio.ts"
"studio": "cd ../example && bunx remotion studio ../web-renderer/src/test/studio.ts --public-dir=../example-videos/videos",
"remotion": "cd ../example && bunx remotion studio ../web-renderer/src/test/studio.ts --public-dir=../example-videos/videos"
},
"author": "Remotion <jonny@remotion.dev>",
"license": "UNLICENSED",
Expand Down
260 changes: 260 additions & 0 deletions packages/web-renderer/src/drawing/calculate-object-fit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
export type ObjectFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';

export type ObjectFitResult = {
// Source rectangle (which part of the image to draw)
sourceX: number;
sourceY: number;
sourceWidth: number;
sourceHeight: number;
// Destination rectangle (where to draw on canvas)
destX: number;
destY: number;
destWidth: number;
destHeight: number;
};

type ObjectFitParams = {
containerSize: {width: number; height: number; left: number; top: number};
intrinsicSize: {width: number; height: number};
};

/**
* fill: Stretch the image to fill the container, ignoring aspect ratio
*/
const calculateFill = ({
containerSize,
intrinsicSize,
}: ObjectFitParams): ObjectFitResult => {
return {
sourceX: 0,
sourceY: 0,
sourceWidth: intrinsicSize.width,
sourceHeight: intrinsicSize.height,
destX: containerSize.left,
destY: containerSize.top,
destWidth: containerSize.width,
destHeight: containerSize.height,
};
};

/**
* contain: Scale the image to fit inside the container while maintaining aspect ratio.
* This may result in letterboxing (empty space on sides or top/bottom).
*/
const calculateContain = ({
containerSize,
intrinsicSize,
}: ObjectFitParams): ObjectFitResult => {
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The calculateContain and calculateCover functions divide by containerSize.height and intrinsicSize.height without checking for zero values. If either of these values is zero, this will result in division by zero, leading to Infinity or NaN values in the calculations. Consider adding validation to ensure both container and intrinsic dimensions are positive before performing these calculations, similar to the validation in fitSvgIntoItsContainer function.

Suggested change
}: ObjectFitParams): ObjectFitResult => {
}: ObjectFitParams): ObjectFitResult => {
if (containerSize.height <= 0 || intrinsicSize.height <= 0) {
// Fall back to fill behavior if heights are invalid to avoid division by zero
return calculateFill({containerSize, intrinsicSize});
}

Copilot uses AI. Check for mistakes.
const containerAspect = containerSize.width / containerSize.height;
const imageAspect = intrinsicSize.width / intrinsicSize.height;

let destWidth: number;
let destHeight: number;

if (imageAspect > containerAspect) {
// Image is wider than container (relative to their heights)
// Fit by width, letterbox top/bottom
destWidth = containerSize.width;
destHeight = containerSize.width / imageAspect;
} else {
// Image is taller than container (relative to their widths)
// Fit by height, letterbox left/right
destHeight = containerSize.height;
destWidth = containerSize.height * imageAspect;
}

// Center the image in the container
const destX = containerSize.left + (containerSize.width - destWidth) / 2;
const destY = containerSize.top + (containerSize.height - destHeight) / 2;

return {
sourceX: 0,
sourceY: 0,
sourceWidth: intrinsicSize.width,
sourceHeight: intrinsicSize.height,
destX,
destY,
destWidth,
destHeight,
};
};

/**
* cover: Scale the image to cover the container while maintaining aspect ratio.
* Parts of the image may be cropped.
*/
const calculateCover = ({
containerSize,
intrinsicSize,
}: ObjectFitParams): ObjectFitResult => {
const containerAspect = containerSize.width / containerSize.height;
const imageAspect = intrinsicSize.width / intrinsicSize.height;

let sourceX = 0;
let sourceY = 0;
let sourceWidth = intrinsicSize.width;
let sourceHeight = intrinsicSize.height;

if (imageAspect > containerAspect) {
// Image is wider than container - crop horizontally
// Scale by height, then crop width
sourceWidth = intrinsicSize.height * containerAspect;
sourceX = (intrinsicSize.width - sourceWidth) / 2;
} else {
// Image is taller than container - crop vertically
// Scale by width, then crop height
sourceHeight = intrinsicSize.width / containerAspect;
sourceY = (intrinsicSize.height - sourceHeight) / 2;
}

return {
sourceX,
sourceY,
sourceWidth,
sourceHeight,
destX: containerSize.left,
destY: containerSize.top,
destWidth: containerSize.width,
destHeight: containerSize.height,
};
};

/**
* none: Draw the image at its natural size, centered in the container.
* Clips to the container bounds if the image overflows.
*/
const calculateNone = ({
containerSize,
intrinsicSize,
}: ObjectFitParams): ObjectFitResult => {
// Calculate centered position (can be negative if image is larger than container)
const centeredX =
containerSize.left + (containerSize.width - intrinsicSize.width) / 2;
const centeredY =
containerSize.top + (containerSize.height - intrinsicSize.height) / 2;

// Calculate clipping bounds
let sourceX = 0;
let sourceY = 0;
let sourceWidth = intrinsicSize.width;
let sourceHeight = intrinsicSize.height;
let destX = centeredX;
let destY = centeredY;
let destWidth = intrinsicSize.width;
let destHeight = intrinsicSize.height;

// Clip left edge
if (destX < containerSize.left) {
const clipAmount = containerSize.left - destX;
sourceX = clipAmount;
sourceWidth -= clipAmount;
destX = containerSize.left;
destWidth -= clipAmount;
}

// Clip top edge
if (destY < containerSize.top) {
const clipAmount = containerSize.top - destY;
sourceY = clipAmount;
sourceHeight -= clipAmount;
destY = containerSize.top;
destHeight -= clipAmount;
}

// Clip right edge
const containerRight = containerSize.left + containerSize.width;
if (destX + destWidth > containerRight) {
const clipAmount = destX + destWidth - containerRight;
sourceWidth -= clipAmount;
destWidth -= clipAmount;
}

// Clip bottom edge
const containerBottom = containerSize.top + containerSize.height;
if (destY + destHeight > containerBottom) {
const clipAmount = destY + destHeight - containerBottom;
sourceHeight -= clipAmount;
destHeight -= clipAmount;
}

return {
sourceX,
sourceY,
sourceWidth,
sourceHeight,
destX,
destY,
destWidth,
destHeight,
};
};

/**
* Calculates how to draw an image based on object-fit CSS property.
*
* @param objectFit - The CSS object-fit value
* @param containerSize - The container dimensions (where the image should be drawn)
* @param intrinsicSize - The natural/intrinsic size of the image
* @returns Source and destination rectangles for drawImage
*/
export const calculateObjectFit = ({
objectFit,
containerSize,
intrinsicSize,
}: {
objectFit: ObjectFit;
} & ObjectFitParams): ObjectFitResult => {
switch (objectFit) {
case 'fill':
return calculateFill({containerSize, intrinsicSize});

case 'contain':
return calculateContain({containerSize, intrinsicSize});

case 'cover':
return calculateCover({containerSize, intrinsicSize});

case 'none':
return calculateNone({containerSize, intrinsicSize});

case 'scale-down': {
// scale-down behaves like contain or none, whichever results in a smaller image
const containResult = calculateContain({containerSize, intrinsicSize});
const noneResult = calculateNone({containerSize, intrinsicSize});

// Compare the rendered size - use whichever is smaller
const containArea = containResult.destWidth * containResult.destHeight;
const noneArea = noneResult.destWidth * noneResult.destHeight;

return containArea < noneArea ? containResult : noneResult;
}

default: {
const exhaustiveCheck: never = objectFit;
throw new Error(`Unknown object-fit value: ${exhaustiveCheck}`);
}
}
};

/**
* Parse an object-fit CSS value string into our ObjectFit type.
* Returns 'fill' as the default if the value is not recognized.
*/
export const parseObjectFit = (value: string | null | undefined): ObjectFit => {
if (!value) {
return 'fill';
}

const normalized = value.trim().toLowerCase();

switch (normalized) {
case 'fill':
case 'contain':
case 'cover':
case 'none':
case 'scale-down':
return normalized;
default:
return 'fill';
}
};
Loading
Loading