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
7 changes: 5 additions & 2 deletions packages/docs/docs/client-side-rendering/limitations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ Basic `box-shadow` are <a style={{color: 'green'}}>supported</a>.

## Masks

`mask-image` with a linear gradient is <a style={{color: 'green'}}>supported</a>.
Other `mask-image` values are <a style={{color: 'red'}}>not supported</a>.
`mask-image` with a linear gradient is <a style={{color: 'green'}}>supported</a>.

`mask-image` with a single raster `url()` is <a style={{color: 'green'}}>supported</a> from <AvailableFrom v="4.0.501" inline /> for same-origin, `data:`, and `blob:` URLs when using `mask-size: 100% 100%`, `mask-position: 0% 0%`, and `mask-repeat: no-repeat`. The mask uses its alpha channel.

Cross-origin URLs, SVG masks, multiple mask layers, luminance masks, and other mask sizing, positioning, or repetition values are <a style={{color: 'red'}}>not supported</a>.

## Filters

Expand Down
14 changes: 11 additions & 3 deletions packages/web-renderer/src/drawing/calculate-transforms.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {hasAnyTransformCssValue, hasTransformCssValue} from './has-transform';
import {makeDOMMatrix} from './make-dom-matrix';
import {getMaskImageValue, parseMaskImage} from './mask-image';
import type {LinearGradientInfo} from './parse-linear-gradient';
import {
getMaskImageValue,
type MaskImageInfo,
parseMaskImage,
validateUrlMaskImageStyle,
} from './mask-image';
import {parseTransformOrigin} from './parse-transform-origin';

/**
Expand Down Expand Up @@ -138,7 +142,7 @@ export const calculateTransforms = ({

let opacity = 1;
let elementComputedStyle: CSSStyleDeclaration | null = null;
let maskImageInfo: LinearGradientInfo | null = null;
let maskImageInfo: MaskImageInfo | null = null;
let filterForPrecompositing: string | null = null;
while (parent) {
// Each node walks its ancestors, so reuse their immutable transform fields
Expand Down Expand Up @@ -174,6 +178,10 @@ export const calculateTransforms = ({
opacity = parseFloat(computedStyle.opacity);
const maskImageValue = getMaskImageValue(computedStyle);
maskImageInfo = maskImageValue ? parseMaskImage(maskImageValue) : null;
if (maskImageInfo?.type === 'url') {
validateUrlMaskImageStyle(computedStyle);
}

filterForPrecompositing = filterRequiresPrecompositing(
computedStyle.filter,
);
Expand Down
46 changes: 33 additions & 13 deletions packages/web-renderer/src/drawing/handle-mask.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {getBiggestBoundingClientRect} from '../get-biggest-bounding-client-rect';
import type {LinearGradientInfo} from './parse-linear-gradient';
import type {InternalState} from '../internal-state';
import type {MaskImageInfo} from './mask-image';
import {getMaskImage} from './mask-image-loader';
import {createCanvasGradient} from './parse-linear-gradient';

export const getPrecomposeRectForMask = (element: HTMLElement | SVGElement) => {
Expand All @@ -8,18 +10,20 @@ export const getPrecomposeRectForMask = (element: HTMLElement | SVGElement) => {
return boundingRect;
};

export const handleMask = ({
gradientInfo,
export const handleMask = async ({
maskImageInfo,
maskRect,
precomposeRect,
tempContext,
scale,
internalState,
}: {
gradientInfo: LinearGradientInfo;
maskImageInfo: MaskImageInfo;
maskRect: DOMRect;
precomposeRect: DOMRect;
tempContext: OffscreenCanvasRenderingContext2D;
scale: number;
internalState: InternalState;
}) => {
const rectToFill = new DOMRect(
(maskRect.left - precomposeRect.left) * scale,
Expand All @@ -28,17 +32,33 @@ export const handleMask = ({
maskRect.height * scale,
);

const gradient = createCanvasGradient({
ctx: tempContext,
rect: rectToFill,
gradientInfo,
offsetLeft: 0,
offsetTop: 0,
});
if (maskImageInfo.type === 'linear-gradient') {
const gradient = createCanvasGradient({
ctx: tempContext,
rect: rectToFill,
gradientInfo: maskImageInfo.gradientInfo,
offsetLeft: 0,
offsetTop: 0,
});

tempContext.globalCompositeOperation = 'destination-in';
tempContext.fillStyle = gradient;
tempContext.fillRect(
rectToFill.left,
rectToFill.top,
rectToFill.width,
rectToFill.height,
);
return;
}

const image = await getMaskImage({
src: maskImageInfo.src,
state: internalState.maskImageLoaderState,
});
tempContext.globalCompositeOperation = 'destination-in';
tempContext.fillStyle = gradient;
tempContext.fillRect(
tempContext.drawImage(
image,
rectToFill.left,
rectToFill.top,
rectToFill.width,
Expand Down
146 changes: 146 additions & 0 deletions packages/web-renderer/src/drawing/mask-image-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
export type MaskImageLoaderState = {
cache: Map<string, Promise<ImageBitmap>>;
images: Set<ImageBitmap>;
signal: AbortSignal | null;
timeoutInMilliseconds: number;
[Symbol.dispose]: () => void;
};

export const makeMaskImageLoaderState = ({
signal,
timeoutInMilliseconds,
}: {
signal: AbortSignal | null;
timeoutInMilliseconds: number;
}): MaskImageLoaderState => {
const images = new Set<ImageBitmap>();

return {
cache: new Map(),
images,
signal,
timeoutInMilliseconds,
[Symbol.dispose]: () => {
for (const image of images) {
image.close();
}

images.clear();
},
};
};

const validateMaskImageUrl = (src: string) => {
const url = new URL(src, document.baseURI);
if (url.protocol === 'data:' || url.protocol === 'blob:') {
return url.href;
}

if (
(url.protocol === 'http:' || url.protocol === 'https:') &&
url.origin === window.location.origin
) {
return url.href;
}

throw new Error(
`@remotion/web-renderer only supports same-origin, data:, and blob: mask-image URLs. Received: ${url.href}`,
);
};

const loadMaskImage = async ({
src,
state,
}: {
src: string;
state: MaskImageLoaderState;
}) => {
const resolvedSrc = validateMaskImageUrl(src);
if (state.signal?.aborted) {
throw new Error(`Loading mask-image URL was cancelled: ${resolvedSrc}`);
}

const controller = new AbortController();
let timedOut = false;
const onAbort = () => controller.abort();
state.signal?.addEventListener('abort', onAbort, {once: true});
const timeout = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, state.timeoutInMilliseconds);

const operation = (async () => {
const response = await fetch(resolvedSrc, {
credentials: 'same-origin',
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}

const blob = await response.blob();
const contentType = blob.type.toLowerCase().split(';')[0];
if (!contentType.startsWith('image/') || contentType === 'image/svg+xml') {
throw new Error(
`Expected a raster image, but received Content-Type "${blob.type || 'unknown'}"`,
);
}

return createImageBitmap(blob);
})();
operation.then(
(image) => {
if (controller.signal.aborted) {
image.close();
}
},
() => undefined,
);
const aborted = new Promise<never>((_, reject) => {
controller.signal.addEventListener(
'abort',
() => reject(new Error('Mask image loading was aborted')),
{once: true},
);
});

try {
const image = await Promise.race([operation, aborted]);
state.images.add(image);
return image;
} catch (error) {
if (timedOut) {
throw new Error(
`Timed out loading mask-image URL after ${state.timeoutInMilliseconds}ms: ${resolvedSrc}`,
);
}

if (state.signal?.aborted) {
throw new Error(`Loading mask-image URL was cancelled: ${resolvedSrc}`);
}

const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Could not load mask-image URL ${resolvedSrc}: ${detail}`);
} finally {
window.clearTimeout(timeout);
state.signal?.removeEventListener('abort', onAbort);
}
};

export const getMaskImage = ({
src,
state,
}: {
src: string;
state: MaskImageLoaderState;
}) => {
const cached = state.cache.get(src);
if (cached) {
return cached;
}

const promise = loadMaskImage({src, state});
state.cache.set(src, promise);
promise.catch(() => state.cache.delete(src));
return promise;
};
Loading
Loading