Add .gitignore to exclude all node packages and lock files

This commit is contained in:
Adolfo Reyna
2026-02-23 21:56:04 -05:00
parent faae96c9ed
commit dcc5c6c044
9747 changed files with 1555105 additions and 2 deletions
@@ -0,0 +1,18 @@
# @remotion/web-renderer
Render videos in the browser (not yet released)
[![NPM Downloads](https://img.shields.io/npm/dm/@remotion/web-renderer.svg?style=flat&color=black&label=Downloads)](https://npmcharts.com/compare/@remotion/web-renderer?minimal=true)
## Installation
```bash
npm install @remotion/web-renderer --save-exact
```
When installing a Remotion package, make sure to align the version of all `remotion` and `@remotion/*` packages to the same version.
Remove the `^` character from the version number to use the exact version.
## Usage
See the [documentation](https://www.remotion.dev/docs/web-renderer/) for more information.
@@ -0,0 +1,3 @@
import type { AudioSampleSource, VideoSampleSource } from 'mediabunny';
export declare const addVideoSampleAndCloseFrame: (frameToEncode: VideoFrame, videoSampleSource: VideoSampleSource) => Promise<void>;
export declare const addAudioSample: (audio: AudioData, audioSampleSource: AudioSampleSource) => Promise<void>;
@@ -0,0 +1,20 @@
import { AudioSample, VideoSample } from 'mediabunny';
export const addVideoSampleAndCloseFrame = async (frameToEncode, videoSampleSource) => {
const sample = new VideoSample(frameToEncode);
try {
await videoSampleSource.add(sample);
}
finally {
sample.close();
frameToEncode.close();
}
};
export const addAudioSample = async (audio, audioSampleSource) => {
const sample = new AudioSample(audio);
try {
await audioSampleSource.add(sample);
}
finally {
sample.close();
}
};
@@ -0,0 +1,23 @@
import type { DownloadBehavior, TRenderAsset } from 'remotion';
export type EmittedArtifact = {
filename: string;
content: string | Uint8Array;
frame: number;
downloadBehavior: DownloadBehavior | null;
};
export declare const onlyArtifact: ({ assets, frameBuffer, }: {
assets: TRenderAsset[];
frameBuffer: Blob | OffscreenCanvas | null;
}) => Promise<EmittedArtifact[]>;
export type WebRendererOnArtifact = (asset: EmittedArtifact) => void;
export type ArtifactsRef = React.RefObject<{
collectAssets: () => TRenderAsset[];
} | null>;
export declare const handleArtifacts: () => {
handle: ({ imageData, frame, assets: artifactAssets, onArtifact, }: {
imageData: Blob | OffscreenCanvas | null;
frame: number;
assets: TRenderAsset[];
onArtifact: WebRendererOnArtifact;
}) => Promise<void>;
};
@@ -0,0 +1,56 @@
import { NoReactInternals } from 'remotion/no-react';
export const onlyArtifact = async ({ assets, frameBuffer, }) => {
const artifacts = assets.filter((asset) => asset.type === 'artifact');
let frameBufferUint8 = null;
const result = [];
for (const artifact of artifacts) {
if (artifact.contentType === 'binary' || artifact.contentType === 'text') {
result.push({
frame: artifact.frame,
content: artifact.content,
filename: artifact.filename,
downloadBehavior: artifact.downloadBehavior,
});
continue;
}
if (artifact.contentType === 'thumbnail') {
if (frameBuffer === null) {
// A thumbnail artifact was defined to be emitted, but the output was not a video.
// Also, in Lambda, there are extra frames which are not video frames.
// This could happen if a thumbnail is unconditionally emitted.
continue;
}
const ab = frameBuffer instanceof Blob
? await frameBuffer.arrayBuffer()
: new Uint8Array(await (await frameBuffer.convertToBlob({ type: 'image/png' })).arrayBuffer());
frameBufferUint8 = new Uint8Array(ab);
result.push({
frame: artifact.frame,
content: frameBufferUint8,
filename: artifact.filename,
downloadBehavior: artifact.downloadBehavior,
});
continue;
}
throw new Error('Unknown artifact type: ' + artifact);
}
return result.filter(NoReactInternals.truthy);
};
export const handleArtifacts = () => {
const previousArtifacts = [];
const handle = async ({ imageData, frame, assets: artifactAssets, onArtifact, }) => {
const artifacts = await onlyArtifact({
assets: artifactAssets,
frameBuffer: imageData,
});
for (const artifact of artifacts) {
const previousArtifact = previousArtifacts.find((a) => a.filename === artifact.filename);
if (previousArtifact) {
throw new Error(`An artifact with output "${artifact.filename}" was already registered at frame ${previousArtifact.frame}, but now registered again at frame ${frame}. Artifacts must have unique names. https://remotion.dev/docs/artifacts`);
}
onArtifact(artifact);
previousArtifacts.push({ frame, filename: artifact.filename });
}
};
return { handle };
};
@@ -0,0 +1,6 @@
import type { TRenderAsset } from 'remotion';
export declare const onlyInlineAudio: ({ assets, fps, timestamp, }: {
assets: TRenderAsset[];
fps: number;
timestamp: number;
}) => AudioData | null;
@@ -0,0 +1,42 @@
const TARGET_NUMBER_OF_CHANNELS = 2;
const TARGET_SAMPLE_RATE = 48000;
function mixAudio(waves, length) {
if (waves.length === 1 && waves[0].length === length) {
return waves[0];
}
const mixed = new Int16Array(length);
if (waves.length === 1) {
mixed.set(waves[0].subarray(0, length));
return mixed;
}
for (let i = 0; i < length; i++) {
const sum = waves.reduce((acc, wave) => {
var _a;
return acc + ((_a = wave[i]) !== null && _a !== void 0 ? _a : 0);
}, 0);
// Clamp to Int16 range
mixed[i] = Math.max(-32768, Math.min(32767, sum));
}
return mixed;
}
export const onlyInlineAudio = ({ assets, fps, frame, }) => {
const inlineAudio = assets.filter((asset) => asset.type === 'inline-audio');
if (inlineAudio.length === 0) {
return null;
}
const expectedLength = Math.round((TARGET_NUMBER_OF_CHANNELS * TARGET_SAMPLE_RATE) / fps);
for (const asset of inlineAudio) {
if (asset.toneFrequency !== 1) {
throw new Error('Setting the toneFrequency is not supported yet in web rendering.');
}
}
const mixedAudio = mixAudio(inlineAudio.map((asset) => asset.audio), expectedLength);
return new AudioData({
data: mixedAudio,
format: 's16',
numberOfChannels: TARGET_NUMBER_OF_CHANNELS,
numberOfFrames: expectedLength / TARGET_NUMBER_OF_CHANNELS,
sampleRate: TARGET_SAMPLE_RATE,
timestamp: (frame / fps) * 1000000,
});
};
@@ -0,0 +1,9 @@
import { type LogLevel } from 'remotion';
export type BackgroundKeepalive = {
waitForTick: () => Promise<void>;
[Symbol.dispose]: () => void;
};
export declare function createBackgroundKeepalive({ fps, logLevel }: {
fps: number;
logLevel: LogLevel;
}): BackgroundKeepalive;
@@ -0,0 +1,3 @@
import type { CanRenderMediaOnWebOptions, CanRenderMediaOnWebResult } from './can-render-types';
export type { CanRenderIssue, CanRenderMediaOnWebOptions, CanRenderMediaOnWebResult, } from './can-render-types';
export declare const canRenderMediaOnWeb: (options: CanRenderMediaOnWebOptions) => Promise<CanRenderMediaOnWebResult>;
@@ -0,0 +1,27 @@
import type { WebRendererAudioCodec, WebRendererContainer, WebRendererQuality, WebRendererVideoCodec } from './mediabunny-mappings';
import type { WebRendererOutputTarget } from './output-target';
export type CanRenderIssueType = 'video-codec-unsupported' | 'audio-codec-unsupported' | 'webgl-unsupported' | 'webcodecs-unavailable' | 'container-codec-mismatch' | 'transparent-video-unsupported' | 'invalid-dimensions' | 'output-target-unsupported';
export type CanRenderIssue = {
type: CanRenderIssueType;
message: string;
severity: 'error' | 'warning';
};
export type CanRenderMediaOnWebResult = {
canRender: boolean;
issues: CanRenderIssue[];
resolvedVideoCodec: WebRendererVideoCodec;
resolvedAudioCodec: WebRendererAudioCodec | null;
resolvedOutputTarget: WebRendererOutputTarget;
};
export type CanRenderMediaOnWebOptions = {
container?: WebRendererContainer;
videoCodec?: WebRendererVideoCodec;
audioCodec?: WebRendererAudioCodec | null;
width: number;
height: number;
transparent?: boolean;
muted?: boolean;
videoBitrate?: number | WebRendererQuality;
audioBitrate?: number | WebRendererQuality;
outputTarget?: WebRendererOutputTarget | null;
};
@@ -0,0 +1 @@
export declare const canUseWebFsWriter: () => Promise<boolean>;
@@ -0,0 +1,19 @@
export const canUseWebFsWriter = async () => {
if (!('storage' in navigator)) {
return false;
}
if (!('getDirectory' in navigator.storage)) {
return false;
}
try {
const directoryHandle = await navigator.storage.getDirectory();
const fileHandle = await directoryHandle.getFileHandle('remotion-probe-web-fs-support', {
create: true,
});
const canUse = fileHandle.createWritable !== undefined;
return canUse;
}
catch (_a) {
return false;
}
};
@@ -0,0 +1,2 @@
import type { CanRenderIssue } from './can-render-types';
export declare const checkWebGLSupport: () => CanRenderIssue | null;
@@ -0,0 +1,26 @@
export declare const compose: ({ element, context, logLevel, parentRect, internalState, onlyBackgroundClipText, scale, }: {
element: HTMLElement | SVGElement;
context: OffscreenCanvasRenderingContext2D;
logLevel: "error" | "info" | "trace" | "verbose" | "warn";
parentRect: DOMRect;
internalState: {
getDrawn3dPixels: () => number;
getPrecomposedTiles: () => number;
addPrecompose: ({ canvasWidth, canvasHeight, }: {
canvasWidth: number;
canvasHeight: number;
}) => void;
helperCanvasState: import("./internal-state").HelperCanvasState;
[Symbol.dispose]: () => void;
getWaitForReadyTime: () => number;
addWaitForReadyTime: (time: number) => void;
getAddSampleTime: () => number;
addAddSampleTime: (time: number) => void;
getCreateFrameTime: () => number;
addCreateFrameTime: (time: number) => void;
getAudioMixingTime: () => number;
addAudioMixingTime: (time: number) => void;
};
onlyBackgroundClipText: boolean;
scale: number;
}) => Promise<void>;
@@ -0,0 +1,85 @@
import { drawDomElement } from './drawing/draw-dom-element';
import { processNode } from './drawing/process-node';
import { handleTextNode } from './drawing/text/handle-text-node';
import { createTreeWalkerCleanupAfterChildren } from './tree-walker-cleanup-after-children';
import { skipToNextNonDescendant } from './walk-tree';
const walkOverNode = ({ node, context, logLevel, parentRect, internalState, rootElement, onlyBackgroundClip, }) => {
if (node instanceof HTMLElement || node instanceof SVGElement) {
return processNode({
element: node,
context,
draw: drawDomElement(node),
logLevel,
parentRect,
internalState,
rootElement,
});
}
if (node instanceof Text) {
return handleTextNode({
node,
context,
logLevel,
parentRect,
internalState,
rootElement,
onlyBackgroundClip,
});
}
throw new Error('Unknown node type');
};
const getFilterFunction = (node) => {
if (!(node instanceof Element)) {
// Must be a text node!
return NodeFilter.FILTER_ACCEPT;
}
// SVG does have children, but we process SVG elements in its
// entirety
if (node.parentElement instanceof SVGSVGElement) {
return NodeFilter.FILTER_REJECT;
}
const computedStyle = getComputedStyle(node);
if (computedStyle.display === 'none') {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
};
export const compose = async ({ element, context, logLevel, parentRect, internalState, onlyBackgroundClip, }) => {
const treeWalker = document.createTreeWalker(element, onlyBackgroundClip
? NodeFilter.SHOW_TEXT
: NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, getFilterFunction);
// Skip to the first text node
if (onlyBackgroundClip) {
treeWalker.nextNode();
if (!treeWalker.currentNode) {
return;
}
}
const { checkCleanUpAtBeginningOfIteration, addCleanup, cleanupInTheEndOfTheIteration, } = createTreeWalkerCleanupAfterChildren(treeWalker);
while (true) {
checkCleanUpAtBeginningOfIteration();
const val = await walkOverNode({
node: treeWalker.currentNode,
context,
logLevel,
parentRect,
internalState,
rootElement: element,
onlyBackgroundClip,
});
if (val.type === 'skip-children') {
if (!skipToNextNonDescendant(treeWalker)) {
break;
}
}
else {
if (val.cleanupAfterChildren) {
addCleanup(treeWalker.currentNode, val.cleanupAfterChildren);
}
if (!treeWalker.nextNode()) {
break;
}
}
}
cleanupInTheEndOfTheIteration();
};
@@ -0,0 +1,9 @@
import { AudioSampleSource, type Quality } from 'mediabunny';
export declare const createAudioSampleSource: ({ muted, codec, bitrate, }: {
muted: boolean;
codec: "aac" | "ac3" | "alaw" | "eac3" | "flac" | "mp3" | "opus" | "pcm-f32" | "pcm-f32be" | "pcm-f64" | "pcm-f64be" | "pcm-s16" | "pcm-s16be" | "pcm-s24" | "pcm-s24be" | "pcm-s32" | "pcm-s32be" | "pcm-s8" | "pcm-u8" | "ulaw" | "vorbis" | null;
bitrate: number | Quality;
}) => {
audioSampleSource: AudioSampleSource;
[Symbol.dispose]: () => void;
} | null;
@@ -0,0 +1,35 @@
import { type ComponentType } from 'react';
import type { Codec, DelayRenderScope, LogLevel, TRenderAsset } from 'remotion';
import type { AnyZodObject } from 'zod';
import type { TimeUpdaterRef } from './update-time';
export type ErrorHolder = {
error: Error | null;
};
export declare function checkForError(errorHolder: ErrorHolder): void;
export declare function createScaffold<Props extends Record<string, unknown>>({ width, height, delayRenderTimeoutInMilliseconds, logLevel, resolvedProps, id, mediaCacheSizeInBytes, durationInFrames, fps, initialFrame, schema, Component, audioEnabled, videoEnabled, defaultCodec, defaultOutName }: {
width: number;
height: number;
delayRenderTimeoutInMilliseconds: number;
logLevel: LogLevel;
resolvedProps: Record<string, unknown>;
id: string;
mediaCacheSizeInBytes: number | null;
initialFrame: number;
durationInFrames: number;
fps: number;
schema: AnyZodObject | null;
Component: ComponentType<Props>;
audioEnabled: boolean;
videoEnabled: boolean;
defaultCodec: Codec | null;
defaultOutName: string | null;
}): {
delayRenderScope: DelayRenderScope;
div: HTMLDivElement;
timeUpdater: React.RefObject<TimeUpdaterRef | null>;
collectAssets: React.RefObject<{
collectAssets: () => TRenderAsset[];
} | null>;
errorHolder: ErrorHolder;
[Symbol.dispose]: () => void;
};
@@ -0,0 +1,104 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { createRef } from 'react';
import { flushSync } from 'react-dom';
import ReactDOM from 'react-dom/client';
import { Internals } from 'remotion';
import { UpdateTime } from './update-time';
import { withResolvers } from './with-resolvers';
export async function createScaffold({ width, height, delayRenderTimeoutInMilliseconds, logLevel, resolvedProps, id, mediaCacheSizeInBytes, durationInFrames, fps, initialFrame, schema, Component, audioEnabled, videoEnabled, defaultCodec, defaultOutName, }) {
if (!ReactDOM.createRoot) {
throw new Error('@remotion/web-renderer requires React 18 or higher');
}
const div = document.createElement('div');
// Match same behavior as in portal-node.ts
div.style.position = 'fixed';
div.style.display = 'flex';
div.style.flexDirection = 'column';
div.style.backgroundColor = 'transparent';
div.style.width = `${width}px`;
div.style.height = `${height}px`;
div.style.zIndex = '-9999';
div.style.top = '0';
div.style.left = '0';
div.style.right = '0';
div.style.bottom = '0';
div.style.visibility = 'hidden';
div.style.pointerEvents = 'none';
const scaffoldClassName = `remotion-scaffold-${Math.random().toString(36).substring(2, 15)}`;
div.className = scaffoldClassName;
const cleanupCSS = Internals.CSSUtils.injectCSS(Internals.CSSUtils.makeDefaultPreviewCSS(`.${scaffoldClassName}`, 'white'));
document.body.appendChild(div);
const { promise, resolve, reject } = withResolvers();
// TODO: This might not work in React 18
const root = ReactDOM.createRoot(div, {
onUncaughtError: (err) => {
reject(err);
},
});
const delayRenderScope = {
remotion_renderReady: true,
remotion_delayRenderTimeouts: {},
remotion_puppeteerTimeout: delayRenderTimeoutInMilliseconds,
remotion_attempt: 0,
remotion_delayRenderHandles: [],
};
const timeUpdater = createRef();
const collectAssets = createRef();
flushSync(() => {
root.render(_jsx(Internals.MaxMediaCacheSizeContext.Provider, { value: mediaCacheSizeInBytes, children: _jsx(Internals.RemotionEnvironmentContext.Provider, { value: {
isStudio: false,
isRendering: true,
isPlayer: false,
isReadOnlyStudio: false,
isClientSideRendering: true,
}, children: _jsx(Internals.DelayRenderContextType.Provider, { value: delayRenderScope, children: _jsx(Internals.CompositionManager.Provider, { value: {
compositions: [
{
id,
// @ts-expect-error
component: Component,
nonce: 0,
defaultProps: {},
folderName: null,
parentFolderName: null,
schema: schema !== null && schema !== void 0 ? schema : null,
calculateMetadata: null,
durationInFrames,
fps,
height,
width,
},
],
canvasContent: {
type: 'composition',
compositionId: id,
},
currentCompositionMetadata: {
props: resolvedProps,
durationInFrames,
fps,
height,
width,
defaultCodec: defaultCodec !== null && defaultCodec !== void 0 ? defaultCodec : null,
defaultOutName: defaultOutName !== null && defaultOutName !== void 0 ? defaultOutName : null,
defaultVideoImageFormat: null,
defaultPixelFormat: null,
defaultProResProfile: null,
},
folders: [],
}, children: _jsx(Internals.RenderAssetManagerProvider, { collectAssets: collectAssets, children: _jsx(UpdateTime, { audioEnabled: audioEnabled, videoEnabled: videoEnabled, logLevel: logLevel, compId: id, initialFrame: initialFrame, timeUpdater: timeUpdater, children: _jsx(Internals.CanUseRemotionHooks.Provider, { value: true, children: _jsx(Component, { ...resolvedProps }) }) }) }) }) }) }) }));
});
resolve();
await promise;
return {
delayRenderScope,
div,
cleanupScaffold: () => {
root.unmount();
div.remove();
cleanupCSS();
},
timeUpdater,
collectAssets,
};
}
@@ -0,0 +1,31 @@
export type BorderRadiusCorners = {
topLeft: {
horizontal: number;
vertical: number;
};
topRight: {
horizontal: number;
vertical: number;
};
bottomRight: {
horizontal: number;
vertical: number;
};
bottomLeft: {
horizontal: number;
vertical: number;
};
};
export declare function parseBorderRadius({ borderRadius, width, height }: {
borderRadius: string;
width: number;
height: number;
}): BorderRadiusCorners;
export declare function setBorderRadius({ ctx, rect, borderRadius, forceClipEvenWhenZero, computedStyle, backgroundClip }: {
ctx: OffscreenCanvasRenderingContext2D;
rect: DOMRect;
borderRadius: BorderRadiusCorners;
forceClipEvenWhenZero: boolean;
computedStyle: CSSStyleDeclaration;
backgroundClip: string;
}): () => void;
@@ -0,0 +1,151 @@
import { drawRoundedRectPath } from './draw-rounded';
import { getBoxBasedOnBackgroundClip } from './get-padding-box';
function parseValue({ value, reference, }) {
value = value.trim();
if (value.endsWith('%')) {
const percentage = parseFloat(value);
return (percentage / 100) * reference;
}
if (value.endsWith('px')) {
return parseFloat(value);
}
// If no unit, assume pixels
return parseFloat(value);
}
function expandShorthand(values) {
if (values.length === 1) {
// All corners the same
return [values[0], values[0], values[0], values[0]];
}
if (values.length === 2) {
// [0] = top-left & bottom-right, [1] = top-right & bottom-left
return [values[0], values[1], values[0], values[1]];
}
if (values.length === 3) {
// [0] = top-left, [1] = top-right & bottom-left, [2] = bottom-right
return [values[0], values[1], values[2], values[1]];
}
// 4 values: top-left, top-right, bottom-right, bottom-left
return [values[0], values[1], values[2], values[3]];
}
function clampBorderRadius({ borderRadius, width, height, }) {
// According to CSS spec, if the sum of border radii on adjacent corners
// exceeds the length of the edge, they should be proportionally reduced
const clamped = {
topLeft: { ...borderRadius.topLeft },
topRight: { ...borderRadius.topRight },
bottomRight: { ...borderRadius.bottomRight },
bottomLeft: { ...borderRadius.bottomLeft },
};
// Check top edge
const topSum = clamped.topLeft.horizontal + clamped.topRight.horizontal;
if (topSum > width) {
const factor = width / topSum;
clamped.topLeft.horizontal *= factor;
clamped.topRight.horizontal *= factor;
}
// Check right edge
const rightSum = clamped.topRight.vertical + clamped.bottomRight.vertical;
if (rightSum > height) {
const factor = height / rightSum;
clamped.topRight.vertical *= factor;
clamped.bottomRight.vertical *= factor;
}
// Check bottom edge
const bottomSum = clamped.bottomRight.horizontal + clamped.bottomLeft.horizontal;
if (bottomSum > width) {
const factor = width / bottomSum;
clamped.bottomRight.horizontal *= factor;
clamped.bottomLeft.horizontal *= factor;
}
// Check left edge
const leftSum = clamped.bottomLeft.vertical + clamped.topLeft.vertical;
if (leftSum > height) {
const factor = height / leftSum;
clamped.bottomLeft.vertical *= factor;
clamped.topLeft.vertical *= factor;
}
return clamped;
}
export function parseBorderRadius({ borderRadius, width, height, }) {
// Split by '/' to separate horizontal and vertical radii
const parts = borderRadius.split('/').map((part) => part.trim());
const horizontalPart = parts[0];
const verticalPart = parts[1];
// Split each part into individual values
const horizontalValues = horizontalPart.split(/\s+/).filter((v) => v);
const verticalValues = verticalPart
? verticalPart.split(/\s+/).filter((v) => v)
: horizontalValues; // If no '/', use horizontal values for vertical
// Expand shorthand to 4 values
const [hTopLeft, hTopRight, hBottomRight, hBottomLeft] = expandShorthand(horizontalValues);
const [vTopLeft, vTopRight, vBottomRight, vBottomLeft] = expandShorthand(verticalValues);
return clampBorderRadius({
borderRadius: {
topLeft: {
horizontal: parseValue({ value: hTopLeft, reference: width }),
vertical: parseValue({ value: vTopLeft, reference: height }),
},
topRight: {
horizontal: parseValue({ value: hTopRight, reference: width }),
vertical: parseValue({ value: vTopRight, reference: height }),
},
bottomRight: {
horizontal: parseValue({ value: hBottomRight, reference: width }),
vertical: parseValue({ value: vBottomRight, reference: height }),
},
bottomLeft: {
horizontal: parseValue({ value: hBottomLeft, reference: width }),
vertical: parseValue({ value: vBottomLeft, reference: height }),
},
},
width,
height,
});
}
export function setBorderRadius({ ctx, rect, borderRadius, forceClipEvenWhenZero = false, computedStyle, backgroundClip, }) {
if (borderRadius.topLeft.horizontal === 0 &&
borderRadius.topLeft.vertical === 0 &&
borderRadius.topRight.horizontal === 0 &&
borderRadius.topRight.vertical === 0 &&
borderRadius.bottomRight.horizontal === 0 &&
borderRadius.bottomRight.vertical === 0 &&
borderRadius.bottomLeft.horizontal === 0 &&
borderRadius.bottomLeft.vertical === 0 &&
!forceClipEvenWhenZero) {
return () => { };
}
ctx.save();
const boundingRect = getBoxBasedOnBackgroundClip(rect, computedStyle, backgroundClip);
// See background-clip tests for why this logic matters!
const actualBorderRadius = {
topLeft: {
horizontal: Math.max(0, borderRadius.topLeft.horizontal - (boundingRect.left - rect.left)),
vertical: Math.max(0, borderRadius.topLeft.vertical - (boundingRect.top - rect.top)),
},
topRight: {
horizontal: Math.max(0, borderRadius.topRight.horizontal - (rect.right - boundingRect.right)),
vertical: Math.max(0, borderRadius.topRight.vertical - (boundingRect.top - rect.top)),
},
bottomRight: {
horizontal: Math.max(0, borderRadius.bottomRight.horizontal - (rect.right - boundingRect.right)),
vertical: Math.max(0, borderRadius.bottomRight.vertical - (rect.bottom - boundingRect.bottom)),
},
bottomLeft: {
horizontal: Math.max(0, borderRadius.bottomLeft.horizontal - (boundingRect.left - rect.left)),
vertical: Math.max(0, borderRadius.bottomLeft.vertical - (rect.bottom - boundingRect.bottom)),
},
};
drawRoundedRectPath({
ctx,
x: boundingRect.left,
y: boundingRect.top,
width: boundingRect.width,
height: boundingRect.height,
borderRadius: actualBorderRadius,
});
ctx.clip();
return () => {
ctx.restore();
};
}
@@ -0,0 +1,40 @@
export type ObjectFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
export type ObjectFitResult = {
sourceX: number;
sourceY: number;
sourceWidth: number;
sourceHeight: number;
destX: number;
destY: number;
destWidth: number;
destHeight: number;
};
type ObjectFitParams = {
containerSize: {
width: number;
height: number;
left: number;
top: number;
};
intrinsicSize: {
width: number;
height: number;
};
};
/**
* 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 declare const calculateObjectFit: ({ objectFit, containerSize, intrinsicSize, }: {
objectFit: ObjectFit;
} & ObjectFitParams) => ObjectFitResult;
/**
* Parse an object-fit CSS value string into our ObjectFit type.
* Returns 'fill' as the default if the value is not recognized.
*/
export declare const parseObjectFit: (value: string | null | undefined) => ObjectFit;
export {};
@@ -0,0 +1,208 @@
/**
* fill: Stretch the image to fill the container, ignoring aspect ratio
*/
const calculateFill = ({ containerSize, intrinsicSize, }) => {
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, }) => {
const containerAspect = containerSize.width / containerSize.height;
const imageAspect = intrinsicSize.width / intrinsicSize.height;
let destWidth;
let destHeight;
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, }) => {
// Guard against zero or non-positive heights to avoid division by zero and NaN/Infinity.
if (containerSize.height <= 0 || intrinsicSize.height <= 0) {
return {
sourceX: 0,
sourceY: 0,
sourceWidth: 0,
sourceHeight: 0,
destX: containerSize.left,
destY: containerSize.top,
destWidth: 0,
destHeight: 0,
};
}
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, }) => {
// 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, }) => {
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 = 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) => {
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';
}
};
@@ -0,0 +1,21 @@
import type { LinearGradientInfo } from './parse-linear-gradient';
export declare const calculateTransforms: ({ element, rootElement, }: {
element: HTMLElement | SVGElement;
rootElement: HTMLElement | SVGElement;
}) => {
dimensions: DOMRect;
totalMatrix: DOMMatrix;
[Symbol.dispose]: () => void;
nativeTransformOrigin: {
x: number;
y: number;
};
computedStyle: CSSStyleDeclaration;
opacity: number;
maskImageInfo: LinearGradientInfo | null;
precompositing: {
needs3DTransformViaWebGL: boolean;
needsMaskImage: LinearGradientInfo | null;
needsPrecompositing: boolean;
};
};
@@ -0,0 +1,127 @@
import { hasAnyTransformCssValue, hasTransformCssValue } from './has-transform';
import { getMaskImageValue, parseMaskImage } from './mask-image';
import { parseTransformOrigin } from './parse-transform-origin';
const getInternalTransformOrigin = (transform) => {
var _a;
const centerX = transform.boundingClientRect.width / 2;
const centerY = transform.boundingClientRect.height / 2;
const origin = (_a = parseTransformOrigin(transform.transformOrigin)) !== null && _a !== void 0 ? _a : {
x: centerX,
y: centerY,
};
return origin;
};
const getGlobalTransformOrigin = ({ transform }) => {
const { x: originX, y: originY } = getInternalTransformOrigin(transform);
return {
x: originX + transform.boundingClientRect.left,
y: originY + transform.boundingClientRect.top,
};
};
export const calculateTransforms = ({ element, rootElement, }) => {
// Compute the cumulative transform by traversing parent nodes
let parent = element;
const transforms = [];
const toReset = [];
let opacity = 1;
let elementComputedStyle = null;
let maskImageInfo = null;
while (parent) {
const computedStyle = getComputedStyle(parent);
if (parent === element) {
elementComputedStyle = computedStyle;
opacity = parseFloat(computedStyle.opacity);
const maskImageValue = getMaskImageValue(computedStyle);
maskImageInfo = maskImageValue ? parseMaskImage(maskImageValue) : null;
const originalMaskImage = parent.style.maskImage;
const originalWebkitMaskImage = parent.style.webkitMaskImage;
parent.style.maskImage = 'none';
parent.style.webkitMaskImage = 'none';
const parentRef = parent;
toReset.push(() => {
parentRef.style.maskImage = originalMaskImage;
parentRef.style.webkitMaskImage = originalWebkitMaskImage;
});
}
if (hasAnyTransformCssValue(computedStyle) || parent === element) {
const toParse = hasTransformCssValue(computedStyle)
? computedStyle.transform
: undefined;
const matrix = new DOMMatrix(toParse);
const { transform, scale, rotate } = parent.style;
const additionalMatrices = [];
// The order of transformations is:
// 1. Translate --> We do not have to consider it since it changes getClientBoundingRect()
// 2. Rotate
// 3. Scale
// 4. CSS "transform"
if (rotate !== '' && rotate !== 'none') {
additionalMatrices.push(new DOMMatrix(`rotate(${rotate})`));
}
if (scale !== '' && scale !== 'none') {
additionalMatrices.push(new DOMMatrix(`scale(${scale})`));
}
additionalMatrices.push(matrix);
parent.style.transform = 'none';
parent.style.scale = 'none';
parent.style.rotate = 'none';
transforms.push({
element: parent,
transformOrigin: computedStyle.transformOrigin,
boundingClientRect: null,
matrices: additionalMatrices,
});
const parentRef = parent;
toReset.push(() => {
parentRef.style.transform = transform;
parentRef.style.scale = scale;
parentRef.style.rotate = rotate;
});
}
if (parent === rootElement) {
break;
}
parent = parent.parentElement;
}
for (const transform of transforms) {
transform.boundingClientRect = transform.element.getBoundingClientRect();
}
const dimensions = transforms[0].boundingClientRect;
const nativeTransformOrigin = getInternalTransformOrigin(transforms[0]);
const totalMatrix = new DOMMatrix();
for (const transform of transforms.slice().reverse()) {
for (const matrix of transform.matrices) {
const globalTransformOrigin = getGlobalTransformOrigin({
transform,
});
const transformMatrix = new DOMMatrix()
.translate(globalTransformOrigin.x, globalTransformOrigin.y)
.multiply(matrix)
.translate(-globalTransformOrigin.x, -globalTransformOrigin.y);
totalMatrix.multiplySelf(transformMatrix);
}
}
if (!elementComputedStyle) {
throw new Error('Element computed style not found');
}
const needs3DTransformViaWebGL = !totalMatrix.is2D;
const needsMaskImage = maskImageInfo !== null;
return {
dimensions,
totalMatrix,
reset: () => {
for (const reset of toReset) {
reset();
}
},
nativeTransformOrigin,
computedStyle: elementComputedStyle,
opacity,
maskImageInfo,
precompositing: {
needs3DTransformViaWebGL,
needsMaskImage: maskImageInfo,
needsPrecompositing: Boolean(needs3DTransformViaWebGL || needsMaskImage),
},
};
};
@@ -0,0 +1,8 @@
export declare const getNarrowerRect: ({ firstRect, secondRect, }: {
firstRect: DOMRect;
secondRect: DOMRect;
}) => DOMRect;
export declare const getWiderRectAndExpand: ({ firstRect, secondRect, }: {
firstRect: DOMRect | null;
secondRect: DOMRect;
}) => DOMRect;
@@ -0,0 +1,18 @@
import { roundToExpandRect } from './round-to-expand-rect';
export const getNarrowerRect = ({ firstRect, secondRect, }) => {
const left = Math.max(firstRect.left, secondRect.left);
const top = Math.max(firstRect.top, secondRect.top);
const bottom = Math.min(firstRect.bottom, secondRect.bottom);
const right = Math.min(firstRect.right, secondRect.right);
return new DOMRect(left, top, right - left, bottom - top);
};
export const getWiderRectAndExpand = ({ firstRect, secondRect, }) => {
if (firstRect === null) {
return roundToExpandRect(secondRect);
}
const left = Math.min(firstRect.left, secondRect.left);
const top = Math.min(firstRect.top, secondRect.top);
const bottom = Math.max(firstRect.bottom, secondRect.bottom);
const right = Math.max(firstRect.right, secondRect.right);
return roundToExpandRect(new DOMRect(left, top, right - left, bottom - top));
};
@@ -0,0 +1 @@
export declare function doRectsIntersect(rect1: DOMRect, rect2: DOMRect): boolean;
@@ -0,0 +1,6 @@
export function doRectsIntersect(rect1, rect2) {
return !(rect1.right <= rect2.left ||
rect1.left >= rect2.right ||
rect1.bottom <= rect2.top ||
rect1.top >= rect2.bottom);
}
@@ -0,0 +1,31 @@
export declare const drawBackground: ({ backgroundImage, context, rect, backgroundColor, backgroundClip, element, logLevel, internalState, computedStyle, offsetLeft: parentOffsetLeft, offsetTop: parentOffsetTop, scale, }: {
backgroundImage: string;
context: OffscreenCanvasRenderingContext2D;
rect: DOMRect;
backgroundColor: string;
backgroundClip: string;
element: HTMLElement | SVGElement;
logLevel: "error" | "info" | "trace" | "verbose" | "warn";
internalState: {
getDrawn3dPixels: () => number;
getPrecomposedTiles: () => number;
addPrecompose: ({ canvasWidth, canvasHeight, }: {
canvasWidth: number;
canvasHeight: number;
}) => void;
helperCanvasState: import("../internal-state").HelperCanvasState;
[Symbol.dispose]: () => void;
getWaitForReadyTime: () => number;
addWaitForReadyTime: (time: number) => void;
getAddSampleTime: () => number;
addAddSampleTime: (time: number) => void;
getCreateFrameTime: () => number;
addCreateFrameTime: (time: number) => void;
getAudioMixingTime: () => number;
addAudioMixingTime: (time: number) => void;
};
computedStyle: CSSStyleDeclaration;
offsetLeft: number;
offsetTop: number;
scale: number;
}) => Promise<void>;
@@ -0,0 +1,62 @@
import { getClippedBackground } from './get-clipped-background';
import { getBoxBasedOnBackgroundClip } from './get-padding-box';
import { createCanvasGradient, parseLinearGradient, } from './parse-linear-gradient';
export const drawBackground = async ({ backgroundImage, context, rect, backgroundColor, backgroundClip, element, logLevel, internalState, computedStyle, offsetLeft: parentOffsetLeft, offsetTop: parentOffsetTop, }) => {
let contextToDraw = context;
const originalCompositeOperation = context.globalCompositeOperation;
let offsetLeft = 0;
let offsetTop = 0;
const finish = () => {
context.globalCompositeOperation = originalCompositeOperation;
if (context !== contextToDraw) {
context.drawImage(contextToDraw.canvas, offsetLeft, offsetTop, contextToDraw.canvas.width, contextToDraw.canvas.height);
}
};
const boundingRect = getBoxBasedOnBackgroundClip(rect, computedStyle, backgroundClip);
if (backgroundClip.includes('text')) {
offsetLeft = boundingRect.left;
offsetTop = boundingRect.top;
const originalBackgroundClip = element.style.backgroundClip;
const originalWebkitBackgroundClip = element.style.webkitBackgroundClip;
element.style.backgroundClip = 'initial';
element.style.webkitBackgroundClip = 'initial';
const drawn = await getClippedBackground({
element,
boundingRect: new DOMRect(boundingRect.left + parentOffsetLeft, boundingRect.top + parentOffsetTop, boundingRect.width, boundingRect.height),
logLevel,
internalState,
});
element.style.backgroundClip = originalBackgroundClip;
element.style.webkitBackgroundClip = originalWebkitBackgroundClip;
contextToDraw = drawn;
contextToDraw.globalCompositeOperation = 'source-in';
}
if (backgroundImage && backgroundImage !== 'none') {
const gradientInfo = parseLinearGradient(backgroundImage);
if (gradientInfo) {
const gradient = createCanvasGradient({
ctx: contextToDraw,
rect: boundingRect,
gradientInfo,
offsetLeft,
offsetTop,
});
const originalFillStyle = contextToDraw.fillStyle;
contextToDraw.fillStyle = gradient;
contextToDraw.fillRect(boundingRect.left - offsetLeft, boundingRect.top - offsetTop, boundingRect.width, boundingRect.height);
contextToDraw.fillStyle = originalFillStyle;
return finish();
}
}
// Fallback to solid background color if no gradient was drawn
if (backgroundColor &&
backgroundColor !== 'transparent' &&
!(backgroundColor.startsWith('rgba') &&
(backgroundColor.endsWith(', 0)') || backgroundColor.endsWith(',0')))) {
const originalFillStyle = contextToDraw.fillStyle;
contextToDraw.fillStyle = backgroundColor;
contextToDraw.fillRect(boundingRect.left - offsetLeft, boundingRect.top - offsetTop, boundingRect.width, boundingRect.height);
contextToDraw.fillStyle = originalFillStyle;
}
finish();
};
@@ -0,0 +1,7 @@
import type { BorderRadiusCorners } from './border-radius';
export declare const drawBorder: ({ ctx, rect, borderRadius, computedStyle, }: {
ctx: OffscreenCanvasRenderingContext2D;
rect: DOMRect;
borderRadius: BorderRadiusCorners;
computedStyle: CSSStyleDeclaration;
}) => void;
@@ -0,0 +1,353 @@
const parseBorderWidth = (value) => {
return parseFloat(value) || 0;
};
const getBorderSideProperties = (computedStyle) => {
// Parse individual border properties for each side
// This handles both shorthand (border: "1px solid red") and longhand (border-top-width: "1px") properties
return {
top: {
width: parseBorderWidth(computedStyle.borderTopWidth),
color: computedStyle.borderTopColor || computedStyle.borderColor || 'black',
style: computedStyle.borderTopStyle || computedStyle.borderStyle || 'solid',
},
right: {
width: parseBorderWidth(computedStyle.borderRightWidth),
color: computedStyle.borderRightColor || computedStyle.borderColor || 'black',
style: computedStyle.borderRightStyle || computedStyle.borderStyle || 'solid',
},
bottom: {
width: parseBorderWidth(computedStyle.borderBottomWidth),
color: computedStyle.borderBottomColor || computedStyle.borderColor || 'black',
style: computedStyle.borderBottomStyle || computedStyle.borderStyle || 'solid',
},
left: {
width: parseBorderWidth(computedStyle.borderLeftWidth),
color: computedStyle.borderLeftColor || computedStyle.borderColor || 'black',
style: computedStyle.borderLeftStyle || computedStyle.borderStyle || 'solid',
},
};
};
const getLineDashPattern = (style, width) => {
if (style === 'dashed') {
return [width * 2, width];
}
if (style === 'dotted') {
return [width, width];
}
return [];
};
const drawBorderSide = ({ ctx, side, x, y, width, height, borderRadius, borderProperties, }) => {
const { width: borderWidth, color, style } = borderProperties;
if (borderWidth <= 0 || style === 'none' || style === 'hidden') {
return;
}
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = borderWidth;
ctx.setLineDash(getLineDashPattern(style, borderWidth));
const halfWidth = borderWidth / 2;
if (side === 'top') {
// Start point (accounting for left border and top-left radius)
const startX = x + borderRadius.topLeft.horizontal;
const startY = y + halfWidth;
// End point (accounting for top-right radius)
const endX = x + width - borderRadius.topRight.horizontal;
const endY = y + halfWidth;
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
}
else if (side === 'right') {
// Start point (accounting for top border and top-right radius)
const startX = x + width - halfWidth;
const startY = y + borderRadius.topRight.vertical;
// End point (accounting for bottom-right radius)
const endX = x + width - halfWidth;
const endY = y + height - borderRadius.bottomRight.vertical;
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
}
else if (side === 'bottom') {
// Start point (accounting for bottom-left radius)
const startX = x + borderRadius.bottomLeft.horizontal;
const startY = y + height - halfWidth;
// End point (accounting for right border and bottom-right radius)
const endX = x + width - borderRadius.bottomRight.horizontal;
const endY = y + height - halfWidth;
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
}
else if (side === 'left') {
// Start point (accounting for top-left radius)
const startX = x + halfWidth;
const startY = y + borderRadius.topLeft.vertical;
// End point (accounting for bottom border and bottom-left radius)
const endX = x + halfWidth;
const endY = y + height - borderRadius.bottomLeft.vertical;
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
}
ctx.stroke();
};
const drawCorner = ({ ctx, corner, x, y, width, height, borderRadius, topBorder, rightBorder, bottomBorder, leftBorder, }) => {
const radius = borderRadius[corner];
if (radius.horizontal <= 0 && radius.vertical <= 0) {
return;
}
let border1;
let border2;
let centerX;
let centerY;
let startAngle;
let endAngle;
if (corner === 'topLeft') {
border1 = leftBorder;
border2 = topBorder;
centerX = x + radius.horizontal;
centerY = y + radius.vertical;
startAngle = Math.PI;
endAngle = (Math.PI * 3) / 2;
}
else if (corner === 'topRight') {
border1 = topBorder;
border2 = rightBorder;
centerX = x + width - radius.horizontal;
centerY = y + radius.vertical;
startAngle = -Math.PI / 2;
endAngle = 0;
}
else if (corner === 'bottomRight') {
border1 = rightBorder;
border2 = bottomBorder;
centerX = x + width - radius.horizontal;
centerY = y + height - radius.vertical;
startAngle = 0;
endAngle = Math.PI / 2;
}
else {
// bottomLeft
border1 = bottomBorder;
border2 = leftBorder;
centerX = x + radius.horizontal;
centerY = y + height - radius.vertical;
startAngle = Math.PI / 2;
endAngle = Math.PI;
}
// Draw corner arc - use the average of the two adjacent borders
// In a more sophisticated implementation, we could blend the two borders
const avgWidth = (border1.width + border2.width) / 2;
const useColor = border1.width >= border2.width ? border1.color : border2.color;
const useStyle = border1.width >= border2.width ? border1.style : border2.style;
if (avgWidth > 0 && useStyle !== 'none' && useStyle !== 'hidden') {
ctx.beginPath();
ctx.strokeStyle = useColor;
ctx.lineWidth = avgWidth;
ctx.setLineDash(getLineDashPattern(useStyle, avgWidth));
// Adjust radius for the border width
const adjustedRadiusH = Math.max(0, radius.horizontal - avgWidth / 2);
const adjustedRadiusV = Math.max(0, radius.vertical - avgWidth / 2);
ctx.ellipse(centerX, centerY, adjustedRadiusH, adjustedRadiusV, 0, startAngle, endAngle);
ctx.stroke();
}
};
const drawUniformBorder = ({ ctx, x, y, width, height, borderRadius, borderWidth, borderColor, borderStyle, }) => {
ctx.beginPath();
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderWidth;
ctx.setLineDash(getLineDashPattern(borderStyle, borderWidth));
const halfWidth = borderWidth / 2;
const borderX = x + halfWidth;
const borderY = y + halfWidth;
const borderW = width - borderWidth;
const borderH = height - borderWidth;
// Adjust border radius for the border width
const adjustedBorderRadius = {
topLeft: {
horizontal: Math.max(0, borderRadius.topLeft.horizontal - halfWidth),
vertical: Math.max(0, borderRadius.topLeft.vertical - halfWidth),
},
topRight: {
horizontal: Math.max(0, borderRadius.topRight.horizontal - halfWidth),
vertical: Math.max(0, borderRadius.topRight.vertical - halfWidth),
},
bottomRight: {
horizontal: Math.max(0, borderRadius.bottomRight.horizontal - halfWidth),
vertical: Math.max(0, borderRadius.bottomRight.vertical - halfWidth),
},
bottomLeft: {
horizontal: Math.max(0, borderRadius.bottomLeft.horizontal - halfWidth),
vertical: Math.max(0, borderRadius.bottomLeft.vertical - halfWidth),
},
};
// Draw continuous path with border radius
ctx.moveTo(borderX + adjustedBorderRadius.topLeft.horizontal, borderY);
// Top edge
ctx.lineTo(borderX + borderW - adjustedBorderRadius.topRight.horizontal, borderY);
// Top-right corner
if (adjustedBorderRadius.topRight.horizontal > 0 ||
adjustedBorderRadius.topRight.vertical > 0) {
ctx.ellipse(borderX + borderW - adjustedBorderRadius.topRight.horizontal, borderY + adjustedBorderRadius.topRight.vertical, adjustedBorderRadius.topRight.horizontal, adjustedBorderRadius.topRight.vertical, 0, -Math.PI / 2, 0);
}
// Right edge
ctx.lineTo(borderX + borderW, borderY + borderH - adjustedBorderRadius.bottomRight.vertical);
// Bottom-right corner
if (adjustedBorderRadius.bottomRight.horizontal > 0 ||
adjustedBorderRadius.bottomRight.vertical > 0) {
ctx.ellipse(borderX + borderW - adjustedBorderRadius.bottomRight.horizontal, borderY + borderH - adjustedBorderRadius.bottomRight.vertical, adjustedBorderRadius.bottomRight.horizontal, adjustedBorderRadius.bottomRight.vertical, 0, 0, Math.PI / 2);
}
// Bottom edge
ctx.lineTo(borderX + adjustedBorderRadius.bottomLeft.horizontal, borderY + borderH);
// Bottom-left corner
if (adjustedBorderRadius.bottomLeft.horizontal > 0 ||
adjustedBorderRadius.bottomLeft.vertical > 0) {
ctx.ellipse(borderX + adjustedBorderRadius.bottomLeft.horizontal, borderY + borderH - adjustedBorderRadius.bottomLeft.vertical, adjustedBorderRadius.bottomLeft.horizontal, adjustedBorderRadius.bottomLeft.vertical, 0, Math.PI / 2, Math.PI);
}
// Left edge
ctx.lineTo(borderX, borderY + adjustedBorderRadius.topLeft.vertical);
// Top-left corner
if (adjustedBorderRadius.topLeft.horizontal > 0 ||
adjustedBorderRadius.topLeft.vertical > 0) {
ctx.ellipse(borderX + adjustedBorderRadius.topLeft.horizontal, borderY + adjustedBorderRadius.topLeft.vertical, adjustedBorderRadius.topLeft.horizontal, adjustedBorderRadius.topLeft.vertical, 0, Math.PI, (Math.PI * 3) / 2);
}
ctx.closePath();
ctx.stroke();
};
export const drawBorder = ({ ctx, rect, borderRadius, computedStyle, }) => {
const borders = getBorderSideProperties(computedStyle);
// Check if we have any visible border
const hasBorder = borders.top.width > 0 ||
borders.right.width > 0 ||
borders.bottom.width > 0 ||
borders.left.width > 0;
if (!hasBorder) {
return;
}
// Save original canvas state
const originalStrokeStyle = ctx.strokeStyle;
const originalLineWidth = ctx.lineWidth;
const originalLineDash = ctx.getLineDash();
// Check if all borders are uniform (same width, color, and style)
const allSidesEqual = borders.top.width === borders.right.width &&
borders.top.width === borders.bottom.width &&
borders.top.width === borders.left.width &&
borders.top.color === borders.right.color &&
borders.top.color === borders.bottom.color &&
borders.top.color === borders.left.color &&
borders.top.style === borders.right.style &&
borders.top.style === borders.bottom.style &&
borders.top.style === borders.left.style &&
borders.top.width > 0;
if (allSidesEqual) {
// Draw as a single continuous border for continuous dashing
drawUniformBorder({
ctx,
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
borderRadius,
borderWidth: borders.top.width,
borderColor: borders.top.color,
borderStyle: borders.top.style,
});
}
else {
// Draw corners first (they go underneath the straight edges)
drawCorner({
ctx,
corner: 'topLeft',
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
borderRadius,
topBorder: borders.top,
rightBorder: borders.right,
bottomBorder: borders.bottom,
leftBorder: borders.left,
});
drawCorner({
ctx,
corner: 'topRight',
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
borderRadius,
topBorder: borders.top,
rightBorder: borders.right,
bottomBorder: borders.bottom,
leftBorder: borders.left,
});
drawCorner({
ctx,
corner: 'bottomRight',
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
borderRadius,
topBorder: borders.top,
rightBorder: borders.right,
bottomBorder: borders.bottom,
leftBorder: borders.left,
});
drawCorner({
ctx,
corner: 'bottomLeft',
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
borderRadius,
topBorder: borders.top,
rightBorder: borders.right,
bottomBorder: borders.bottom,
leftBorder: borders.left,
});
// Draw each border side
drawBorderSide({
ctx,
side: 'top',
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
borderRadius,
borderProperties: borders.top,
});
drawBorderSide({
ctx,
side: 'right',
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
borderRadius,
borderProperties: borders.right,
});
drawBorderSide({
ctx,
side: 'bottom',
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
borderRadius,
borderProperties: borders.bottom,
});
drawBorderSide({
ctx,
side: 'left',
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
borderRadius,
borderProperties: borders.left,
});
}
// Restore original canvas state
ctx.strokeStyle = originalStrokeStyle;
ctx.lineWidth = originalLineWidth;
ctx.setLineDash(originalLineDash);
};
@@ -0,0 +1,17 @@
import type { BorderRadiusCorners } from './border-radius';
interface BoxShadow {
offsetX: number;
offsetY: number;
blurRadius: number;
color: string;
inset: boolean;
}
export declare const parseBoxShadow: (boxShadowValue: string) => BoxShadow[];
export declare const drawBorderRadius: ({ ctx, rect, borderRadius, computedStyle, logLevel, }: {
ctx: OffscreenCanvasRenderingContext2D;
rect: DOMRect;
borderRadius: BorderRadiusCorners;
computedStyle: CSSStyleDeclaration;
logLevel: "error" | "info" | "trace" | "verbose" | "warn";
}) => void;
export {};
@@ -0,0 +1,103 @@
import { Internals } from 'remotion';
import { drawRoundedRectPath } from './draw-rounded';
export const parseBoxShadow = (boxShadowValue) => {
if (!boxShadowValue || boxShadowValue === 'none') {
return [];
}
const shadows = [];
// Split by comma, but respect rgba() colors
const shadowStrings = boxShadowValue.split(/,(?![^(]*\))/);
for (const shadowStr of shadowStrings) {
const trimmed = shadowStr.trim();
if (!trimmed || trimmed === 'none') {
continue;
}
const shadow = {
offsetX: 0,
offsetY: 0,
blurRadius: 0,
color: 'rgba(0, 0, 0, 0.5)',
inset: false,
};
// Check for inset
shadow.inset = /\binset\b/i.test(trimmed);
// Remove 'inset' keyword
let remaining = trimmed.replace(/\binset\b/gi, '').trim();
// Extract color (can be rgb(), rgba(), hsl(), hsla(), hex, or named color)
const colorMatch = remaining.match(/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-f]{3,8}|[a-z]+)/i);
if (colorMatch) {
shadow.color = colorMatch[0];
remaining = remaining.replace(colorMatch[0], '').trim();
}
// Parse remaining numeric values (offset-x offset-y blur spread)
const numbers = remaining.match(/[+-]?\d*\.?\d+(?:px|em|rem|%)?/gi) || [];
const values = numbers.map((n) => parseFloat(n) || 0);
if (values.length >= 2) {
shadow.offsetX = values[0];
shadow.offsetY = values[1];
if (values.length >= 3) {
shadow.blurRadius = Math.max(0, values[2]); // Blur cannot be negative
}
}
shadows.push(shadow);
}
return shadows;
};
export const drawBorderRadius = ({ ctx, rect, borderRadius, computedStyle, logLevel, }) => {
const shadows = parseBoxShadow(computedStyle.boxShadow);
if (shadows.length === 0) {
return;
}
// Draw shadows from last to first (so first shadow appears on top)
for (let i = shadows.length - 1; i >= 0; i--) {
const shadow = shadows[i];
const newLeft = rect.left + Math.min(shadow.offsetX, 0) - shadow.blurRadius;
const newRight = rect.right + Math.max(shadow.offsetX, 0) + shadow.blurRadius;
const newTop = rect.top + Math.min(shadow.offsetY, 0) - shadow.blurRadius;
const newBottom = rect.bottom + Math.max(shadow.offsetY, 0) + shadow.blurRadius;
const newRect = new DOMRect(newLeft, newTop, newRight - newLeft, newBottom - newTop);
const leftOffset = rect.left - newLeft;
const topOffset = rect.top - newTop;
const newCanvas = new OffscreenCanvas(newRect.width, newRect.height);
const newCtx = newCanvas.getContext('2d');
if (!newCtx) {
throw new Error('Failed to get context');
}
if (shadow.inset) {
// TODO: Only warn once per render.
Internals.Log.warn({
logLevel,
tag: '@remotion/web-renderer',
}, 'Detected "box-shadow" with "inset". This is not yet supported in @remotion/web-renderer');
continue;
}
// Apply shadow properties to canvas
newCtx.shadowBlur = shadow.blurRadius;
newCtx.shadowColor = shadow.color;
newCtx.shadowOffsetX = shadow.offsetX;
newCtx.shadowOffsetY = shadow.offsetY;
newCtx.fillStyle = 'black';
drawRoundedRectPath({
ctx: newCtx,
x: leftOffset,
y: topOffset,
width: rect.width,
height: rect.height,
borderRadius,
});
newCtx.fill();
// Cut out the shape, leaving only shadow
newCtx.shadowColor = 'transparent';
newCtx.globalCompositeOperation = 'destination-out';
drawRoundedRectPath({
ctx: newCtx,
x: leftOffset,
y: topOffset,
width: rect.width,
height: rect.height,
borderRadius,
});
newCtx.fill();
ctx.drawImage(newCanvas, rect.left - leftOffset, rect.top - topOffset);
}
};
@@ -0,0 +1,2 @@
import type { DrawFn } from './drawn-fn';
export declare const drawDomElement: (node: HTMLElement | SVGElement) => DrawFn;
@@ -0,0 +1,85 @@
import { calculateObjectFit, parseObjectFit } from './calculate-object-fit';
import { fitSvgIntoItsContainer } from './fit-svg-into-its-dimensions';
import { turnSvgIntoDrawable } from './turn-svg-into-drawable';
const getReadableImageError = (err, node) => {
if (!(err instanceof DOMException)) {
return null;
}
if (err.name === 'SecurityError') {
return new Error(`Could not draw image with src="${node.src}" to canvas: ` +
`The image is tainted due to CORS restrictions. ` +
`The server hosting this image must respond with the "Access-Control-Allow-Origin" header. ` +
`See: https://remotion.dev/docs/client-side-rendering/migration`);
}
if (err.name === 'InvalidStateError') {
return new Error(`Could not draw image with src="${node.src}" to canvas: ` +
`The image is in a broken state. ` +
`This usually means the image failed to load - check that the URL is valid and accessible.`);
}
return null;
};
/**
* Draw an SVG element using "contain" behavior (the default for SVGs).
*/
const drawSvg = ({ drawable, dimensions, contextToDraw, }) => {
const fitted = fitSvgIntoItsContainer({
containerSize: dimensions,
elementSize: {
width: drawable.width,
height: drawable.height,
},
});
contextToDraw.drawImage(drawable, fitted.left, fitted.top, fitted.width, fitted.height);
};
/**
* Draw an image or canvas element using the object-fit CSS property.
*/
const drawReplacedElement = ({ drawable, dimensions, computedStyle, contextToDraw, }) => {
const objectFit = parseObjectFit(computedStyle.objectFit);
const intrinsicSize = drawable instanceof HTMLImageElement
? { width: drawable.naturalWidth, height: drawable.naturalHeight }
: { width: drawable.width, height: drawable.height };
const result = calculateObjectFit({
objectFit,
containerSize: {
width: dimensions.width,
height: dimensions.height,
left: dimensions.left,
top: dimensions.top,
},
intrinsicSize,
});
// Use the 9-argument drawImage to support source cropping (for cover mode)
contextToDraw.drawImage(drawable, result.sourceX, result.sourceY, result.sourceWidth, result.sourceHeight, result.destX, result.destY, result.destWidth, result.destHeight);
};
export const drawDomElement = (node) => {
const domDrawFn = async ({ dimensions, contextToDraw, computedStyle, }) => {
// Handle SVG elements separately - they use "contain" behavior by default
if (node instanceof SVGSVGElement) {
const drawable = await turnSvgIntoDrawable(node);
drawSvg({ drawable, dimensions, contextToDraw });
return;
}
// Handle replaced elements (img, canvas) with object-fit support
if (node instanceof HTMLImageElement || node instanceof HTMLCanvasElement) {
try {
drawReplacedElement({
drawable: node,
dimensions,
computedStyle,
contextToDraw,
});
}
catch (err) {
if (node instanceof HTMLImageElement) {
const readableError = getReadableImageError(err, node);
if (readableError) {
throw readableError;
}
}
throw err;
}
}
};
return domDrawFn;
};
@@ -0,0 +1,33 @@
import type { DrawFn } from './drawn-fn';
export declare const drawElement: ({ rect, computedStyle, context, draw, opacity, totalMatrix, parentRect, logLevel, element, internalState, scale, }: {
rect: DOMRect;
computedStyle: CSSStyleDeclaration;
context: OffscreenCanvasRenderingContext2D;
opacity: number;
totalMatrix: DOMMatrix;
draw: DrawFn;
parentRect: DOMRect;
logLevel: "error" | "info" | "trace" | "verbose" | "warn";
element: HTMLElement | SVGElement;
internalState: {
getDrawn3dPixels: () => number;
getPrecomposedTiles: () => number;
addPrecompose: ({ canvasWidth, canvasHeight, }: {
canvasWidth: number;
canvasHeight: number;
}) => void;
helperCanvasState: import("../internal-state").HelperCanvasState;
[Symbol.dispose]: () => void;
getWaitForReadyTime: () => number;
addWaitForReadyTime: (time: number) => void;
getAddSampleTime: () => number;
addAddSampleTime: (time: number) => void;
getCreateFrameTime: () => number;
addCreateFrameTime: (time: number) => void;
getAudioMixingTime: () => number;
addAudioMixingTime: (time: number) => void;
};
scale: number;
}) => Promise<{
cleanupAfterChildren: () => void;
}>;
@@ -0,0 +1,84 @@
import { parseBorderRadius, setBorderRadius } from './border-radius';
import { drawBackground } from './draw-background';
import { drawBorder } from './draw-border';
import { drawBorderRadius } from './draw-box-shadow';
import { drawOutline } from './draw-outline';
import { setOpacity } from './opacity';
import { setOverflowHidden } from './overflow';
import { setTransform } from './transform';
export const drawElement = async ({ rect, computedStyle, context, draw, opacity, totalMatrix, parentRect, logLevel, element, internalState, }) => {
const { backgroundImage, backgroundColor, backgroundClip } = computedStyle;
const borderRadius = parseBorderRadius({
borderRadius: computedStyle.borderRadius,
width: rect.width,
height: rect.height,
});
const finishTransform = setTransform({
ctx: context,
transform: totalMatrix,
parentRect,
});
const finishOpacity = setOpacity({
ctx: context,
opacity,
});
// Draw box shadow before border radius clip and background
drawBorderRadius({
ctx: context,
computedStyle,
rect,
borderRadius,
logLevel,
});
const finishBorderRadius = setBorderRadius({
ctx: context,
rect,
borderRadius,
forceClipEvenWhenZero: false,
computedStyle,
backgroundClip,
});
await drawBackground({
backgroundImage,
context,
rect,
backgroundColor,
backgroundClip,
element,
logLevel,
internalState,
computedStyle,
offsetLeft: parentRect.left,
offsetTop: parentRect.top,
});
await draw({ dimensions: rect, computedStyle, contextToDraw: context });
finishBorderRadius();
drawBorder({
ctx: context,
rect,
borderRadius,
computedStyle,
});
// Drawing outline ignores overflow: hidden, finishing it and starting a new one for the outline
drawOutline({
ctx: context,
rect,
borderRadius,
computedStyle,
});
const finishOverflowHidden = setOverflowHidden({
ctx: context,
rect,
borderRadius,
overflowHidden: computedStyle.overflow === 'hidden',
computedStyle,
backgroundClip,
});
finishTransform();
return {
cleanupAfterChildren: () => {
finishOpacity();
finishOverflowHidden();
},
};
};
@@ -0,0 +1,9 @@
import type { BorderRadiusCorners } from './border-radius';
export declare const parseOutlineWidth: (value: string) => number;
export declare const parseOutlineOffset: (value: string) => number;
export declare const drawOutline: ({ ctx, rect, borderRadius, computedStyle, }: {
ctx: OffscreenCanvasRenderingContext2D;
rect: DOMRect;
borderRadius: BorderRadiusCorners;
computedStyle: CSSStyleDeclaration;
}) => void;
@@ -0,0 +1,93 @@
import { drawRoundedRectPath } from './draw-rounded';
export const parseOutlineWidth = (value) => {
return parseFloat(value) || 0;
};
export const parseOutlineOffset = (value) => {
return parseFloat(value) || 0;
};
const getLineDashPattern = (style, width) => {
if (style === 'dashed') {
return [width * 2, width];
}
if (style === 'dotted') {
return [width, width];
}
return [];
};
export const drawOutline = ({ ctx, rect, borderRadius, computedStyle, }) => {
const outlineWidth = parseOutlineWidth(computedStyle.outlineWidth);
const { outlineStyle } = computedStyle;
const outlineColor = computedStyle.outlineColor || 'black';
const outlineOffset = parseOutlineOffset(computedStyle.outlineOffset);
// Check if we have a visible outline
if (outlineWidth <= 0 ||
outlineStyle === 'none' ||
outlineStyle === 'hidden') {
return;
}
// Save original canvas state
const originalStrokeStyle = ctx.strokeStyle;
const originalLineWidth = ctx.lineWidth;
const originalLineDash = ctx.getLineDash();
ctx.strokeStyle = outlineColor;
ctx.lineWidth = outlineWidth;
ctx.setLineDash(getLineDashPattern(outlineStyle, outlineWidth));
// Calculate outline position
// Outline is drawn outside the border edge, offset by outlineOffset
const halfWidth = outlineWidth / 2;
const offset = outlineOffset + halfWidth;
const outlineX = rect.left - offset;
const outlineY = rect.top - offset;
const outlineW = rect.width + offset * 2;
const outlineH = rect.height + offset * 2;
// Adjust border radius for the outline offset
// When outline-offset is positive, we need to expand the radius
// When outline-offset is negative, we need to shrink the radius
const adjustedBorderRadius = {
topLeft: {
horizontal: borderRadius.topLeft.horizontal === 0
? 0
: Math.max(0, borderRadius.topLeft.horizontal + offset),
vertical: borderRadius.topLeft.vertical === 0
? 0
: Math.max(0, borderRadius.topLeft.vertical + offset),
},
topRight: {
horizontal: borderRadius.topRight.horizontal === 0
? 0
: Math.max(0, borderRadius.topRight.horizontal + offset),
vertical: borderRadius.topRight.vertical === 0
? 0
: Math.max(0, borderRadius.topRight.vertical + offset),
},
bottomRight: {
horizontal: borderRadius.bottomRight.horizontal === 0
? 0
: Math.max(0, borderRadius.bottomRight.horizontal + offset),
vertical: borderRadius.bottomRight.vertical === 0
? 0
: Math.max(0, borderRadius.bottomRight.vertical + offset),
},
bottomLeft: {
horizontal: borderRadius.bottomLeft.horizontal === 0
? 0
: Math.max(0, borderRadius.bottomLeft.horizontal + offset),
vertical: borderRadius.bottomLeft.vertical === 0
? 0
: Math.max(0, borderRadius.bottomLeft.vertical + offset),
},
};
drawRoundedRectPath({
ctx,
x: outlineX,
y: outlineY,
width: outlineW,
height: outlineH,
borderRadius: adjustedBorderRadius,
});
ctx.stroke();
// Restore original canvas state
ctx.strokeStyle = originalStrokeStyle;
ctx.lineWidth = originalLineWidth;
ctx.setLineDash(originalLineDash);
};
@@ -0,0 +1,9 @@
import type { BorderRadiusCorners } from './border-radius';
export declare const drawRoundedRectPath: ({ ctx, x, y, width, height, borderRadius, }: {
ctx: OffscreenCanvasRenderingContext2D;
x: number;
y: number;
width: number;
height: number;
borderRadius: BorderRadiusCorners;
}) => void;
@@ -0,0 +1,34 @@
export const drawRoundedRectPath = ({ ctx, x, y, width, height, borderRadius, }) => {
ctx.beginPath();
// Start from top-left corner, after the radius
ctx.moveTo(x + borderRadius.topLeft.horizontal, y);
// Top edge
ctx.lineTo(x + width - borderRadius.topRight.horizontal, y);
// Top-right corner
if (borderRadius.topRight.horizontal > 0 ||
borderRadius.topRight.vertical > 0) {
ctx.ellipse(x + width - borderRadius.topRight.horizontal, y + borderRadius.topRight.vertical, borderRadius.topRight.horizontal, borderRadius.topRight.vertical, 0, -Math.PI / 2, 0);
}
// Right edge
ctx.lineTo(x + width, y + height - borderRadius.bottomRight.vertical);
// Bottom-right corner
if (borderRadius.bottomRight.horizontal > 0 ||
borderRadius.bottomRight.vertical > 0) {
ctx.ellipse(x + width - borderRadius.bottomRight.horizontal, y + height - borderRadius.bottomRight.vertical, borderRadius.bottomRight.horizontal, borderRadius.bottomRight.vertical, 0, 0, Math.PI / 2);
}
// Bottom edge
ctx.lineTo(x + borderRadius.bottomLeft.horizontal, y + height);
// Bottom-left corner
if (borderRadius.bottomLeft.horizontal > 0 ||
borderRadius.bottomLeft.vertical > 0) {
ctx.ellipse(x + borderRadius.bottomLeft.horizontal, y + height - borderRadius.bottomLeft.vertical, borderRadius.bottomLeft.horizontal, borderRadius.bottomLeft.vertical, 0, Math.PI / 2, Math.PI);
}
// Left edge
ctx.lineTo(x, y + borderRadius.topLeft.vertical);
// Top-left corner
if (borderRadius.topLeft.horizontal > 0 ||
borderRadius.topLeft.vertical > 0) {
ctx.ellipse(x + borderRadius.topLeft.horizontal, y + borderRadius.topLeft.vertical, borderRadius.topLeft.horizontal, borderRadius.topLeft.vertical, 0, Math.PI, (Math.PI * 3) / 2);
}
ctx.closePath();
};
@@ -0,0 +1,5 @@
export type DrawFn = ({ computedStyle, contextToDraw, dimensions }: {
dimensions: DOMRect;
computedStyle: CSSStyleDeclaration;
contextToDraw: OffscreenCanvasRenderingContext2D;
}) => Promise<void> | void;
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,12 @@
export declare const fitSvgIntoItsContainer: ({ containerSize, elementSize, }: {
containerSize: DOMRect;
elementSize: {
width: number;
height: number;
};
}) => {
width: number;
height: number;
top: number;
left: number;
};
@@ -0,0 +1,35 @@
// The bitmap created from the SVG height and width might not be what we expect.
// Adjust the dimensions
export const fitSvgIntoItsContainer = ({ containerSize, elementSize, }) => {
// If was already fitting, no need to calculate and lose precision
if (Math.round(containerSize.width) === Math.round(elementSize.width) &&
Math.round(containerSize.height) === Math.round(elementSize.height)) {
return {
width: containerSize.width,
height: containerSize.height,
top: containerSize.top,
left: containerSize.left,
};
}
if (containerSize.width <= 0 || containerSize.height <= 0) {
throw new Error(`Container must have positive dimensions, but got ${containerSize.width}x${containerSize.height}`);
}
if (elementSize.width <= 0 || elementSize.height <= 0) {
throw new Error(`Element must have positive dimensions, but got ${elementSize.width}x${elementSize.height}`);
}
const heightRatio = containerSize.height / elementSize.height;
const widthRatio = containerSize.width / elementSize.width;
const ratio = Math.min(heightRatio, widthRatio);
const newWidth = elementSize.width * ratio;
const newHeight = elementSize.height * ratio;
if (newWidth > containerSize.width + 0.000001 ||
newHeight > containerSize.height + 0.000001) {
throw new Error(`Element is too big to fit into the container. Max size: ${containerSize.width}x${containerSize.height}, element size: ${newWidth}x${newHeight}`);
}
return {
width: newWidth,
height: newHeight,
top: (containerSize.height - newHeight) / 2 + containerSize.top,
left: (containerSize.width - newWidth) / 2 + containerSize.left,
};
};
@@ -0,0 +1,8 @@
export declare const getBackgroundFill: ({ backgroundColor, backgroundImage, contextToDraw, boundingRect, offsetLeft, offsetTop, }: {
backgroundImage: string;
backgroundColor: string;
contextToDraw: OffscreenCanvasRenderingContext2D;
boundingRect: DOMRect;
offsetLeft: number;
offsetTop: number;
}) => string | CanvasGradient | null;
@@ -0,0 +1,8 @@
import type { LogLevel } from 'remotion';
import type { InternalState } from '../internal-state';
export declare const getClippedBackground: ({ element, boundingRect, logLevel, internalState, }: {
element: HTMLElement | SVGElement;
boundingRect: DOMRect;
logLevel: LogLevel;
internalState: InternalState;
}) => Promise<OffscreenCanvasRenderingContext2D>;
@@ -0,0 +1,14 @@
import { compose } from '../compose';
export const getClippedBackground = async ({ element, boundingRect, logLevel, internalState, }) => {
const tempCanvas = new OffscreenCanvas(boundingRect.width, boundingRect.height);
const tempContext = tempCanvas.getContext('2d');
await compose({
element,
context: tempContext,
logLevel,
parentRect: boundingRect,
internalState,
onlyBackgroundClip: true,
});
return tempContext;
};
@@ -0,0 +1 @@
export declare const getBoxBasedOnBackgroundClip: (rect: DOMRect, computedStyle: CSSStyleDeclaration, backgroundClip: string | undefined) => DOMRect;
@@ -0,0 +1,30 @@
const getPaddingBox = (rect, computedStyle) => {
const borderLeft = parseFloat(computedStyle.borderLeftWidth);
const borderRight = parseFloat(computedStyle.borderRightWidth);
const borderTop = parseFloat(computedStyle.borderTopWidth);
const borderBottom = parseFloat(computedStyle.borderBottomWidth);
return new DOMRect(rect.left + borderLeft, rect.top + borderTop, rect.width - borderLeft - borderRight, rect.height - borderTop - borderBottom);
};
const getContentBox = (rect, computedStyle) => {
const paddingBox = getPaddingBox(rect, computedStyle);
const paddingLeft = parseFloat(computedStyle.paddingLeft);
const paddingRight = parseFloat(computedStyle.paddingRight);
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
return new DOMRect(paddingBox.left + paddingLeft, paddingBox.top + paddingTop, paddingBox.width - paddingLeft - paddingRight, paddingBox.height - paddingTop - paddingBottom);
};
export const getBoxBasedOnBackgroundClip = (rect, computedStyle, backgroundClip) => {
if (!backgroundClip) {
return rect;
}
if (backgroundClip.includes('text')) {
return rect;
}
if (backgroundClip.includes('padding-box')) {
return getPaddingBox(rect, computedStyle);
}
if (backgroundClip.includes('content-box')) {
return getContentBox(rect, computedStyle);
}
return rect;
};
@@ -0,0 +1 @@
export declare function getPreTransformRect(targetRect: DOMRect, matrix: DOMMatrix): DOMRect | null;
@@ -0,0 +1,49 @@
// In some cases, we get a matrix that is too compressed:
// e.g. https://github.com/remotion-dev/remotion/issues/6185
// > You're rotating around the X-axis by ~89.96°, which means the Y-axis gets compressed to cos(89.96°) ≈ 0.000691 of its original size in the viewport.
const MAX_SCALE_FACTOR = 100;
export function getPreTransformRect(targetRect, matrix) {
// 1. Determine the effective 2D transformation by transforming basis vectors
const origin = new DOMPoint(0, 0).matrixTransform(matrix);
const unitX = new DOMPoint(1, 0).matrixTransform(matrix);
const unitY = new DOMPoint(0, 1).matrixTransform(matrix);
// 2. Compute the 2D basis vectors after transformation
const basisX = { x: unitX.x - origin.x, y: unitX.y - origin.y };
const basisY = { x: unitY.x - origin.x, y: unitY.y - origin.y };
// Check effective scale in each axis
const scaleX = Math.hypot(basisX.x, basisX.y);
const scaleY = Math.hypot(basisY.x, basisY.y);
// If either axis is too compressed, the inverse will explode
const minScale = Math.min(scaleX, scaleY);
if (minScale < 1 / MAX_SCALE_FACTOR) {
// Content is nearly invisible, e.g. 89.96deg X rotation (edge-on view)
return new DOMRect(0, 0, 0, 0);
}
// 3. Build the effective 2D matrix and invert it
const effective2D = new DOMMatrix([
basisX.x,
basisX.y, // a, b (first column)
basisY.x,
basisY.y, // c, d (second column)
origin.x,
origin.y, // e, f (translation)
]);
const inverse2D = effective2D.inverse();
const wasNotInvertible = isNaN(inverse2D.m11);
// For example, a 90 degree rotation, is not being rendered
if (wasNotInvertible) {
return new DOMRect(0, 0, 0, 0);
}
// 4. Transform target rect corners using the 2D inverse
const corners = [
new DOMPoint(targetRect.x, targetRect.y),
new DOMPoint(targetRect.x + targetRect.width, targetRect.y),
new DOMPoint(targetRect.x + targetRect.width, targetRect.y + targetRect.height),
new DOMPoint(targetRect.x, targetRect.y + targetRect.height),
];
const transformedCorners = corners.map((c) => c.matrixTransform(inverse2D));
// 5. Compute bounding box
const xs = transformedCorners.map((p) => p.x);
const ys = transformedCorners.map((p) => p.y);
return new DOMRect(Math.min(...xs), Math.min(...ys), Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
}
@@ -0,0 +1,30 @@
export declare const getPrecomposeRectFor3DTransform: ({ element, parentRect, matrix, }: {
element: HTMLElement | SVGElement;
parentRect: DOMRect;
matrix: DOMMatrix;
}) => DOMRect | null;
export declare const handle3dTransform: ({ matrix, sourceRect, tempCanvas, rectAfterTransforms, internalState, scale, }: {
matrix: DOMMatrix;
sourceRect: DOMRect;
tempCanvas: OffscreenCanvas;
rectAfterTransforms: DOMRect;
internalState: {
getDrawn3dPixels: () => number;
getPrecomposedTiles: () => number;
addPrecompose: ({ canvasWidth, canvasHeight, }: {
canvasWidth: number;
canvasHeight: number;
}) => void;
helperCanvasState: import("../internal-state").HelperCanvasState;
[Symbol.dispose]: () => void;
getWaitForReadyTime: () => number;
addWaitForReadyTime: (time: number) => void;
getAddSampleTime: () => number;
addAddSampleTime: (time: number) => void;
getCreateFrameTime: () => number;
addCreateFrameTime: (time: number) => void;
getAudioMixingTime: () => number;
addAudioMixingTime: (time: number) => void;
};
scale: number;
}) => OffscreenCanvas | null;
@@ -0,0 +1,26 @@
import { getBiggestBoundingClientRect } from '../get-biggest-bounding-client-rect';
import { getNarrowerRect } from './clamp-rect-to-parent-bounds';
import { getPreTransformRect } from './get-pretransform-rect';
import { transformIn3d } from './transform-in-3d';
export const getPrecomposeRectFor3DTransform = ({ element, parentRect, matrix, }) => {
const unclampedBiggestBoundingClientRect = getBiggestBoundingClientRect(element);
const biggestPossiblePretransformRect = getPreTransformRect(parentRect, matrix);
const preTransformRect = getNarrowerRect({
firstRect: unclampedBiggestBoundingClientRect,
secondRect: biggestPossiblePretransformRect,
});
return preTransformRect;
};
export const handle3dTransform = ({ matrix, precomposeRect, tempCanvas, rectAfterTransforms, internalState, }) => {
const { canvas: transformed, rect: transformedRect } = transformIn3d({
untransformedRect: precomposeRect,
matrix,
sourceCanvas: tempCanvas,
rectAfterTransforms,
internalState,
});
if (transformedRect.width <= 0 || transformedRect.height <= 0) {
return null;
}
return transformed;
};
@@ -0,0 +1,9 @@
import type { LinearGradientInfo } from './parse-linear-gradient';
export declare const getPrecomposeRectForMask: (element: HTMLElement | SVGElement) => DOMRect;
export declare const handleMask: ({ gradientInfo, rect, precomposeRect, tempContext, scale, }: {
gradientInfo: LinearGradientInfo;
rect: DOMRect;
precomposeRect: DOMRect;
tempContext: OffscreenCanvasRenderingContext2D;
scale: number;
}) => void;
@@ -0,0 +1,21 @@
import { getBiggestBoundingClientRect } from '../get-biggest-bounding-client-rect';
import { createCanvasGradient } from './parse-linear-gradient';
export const getPrecomposeRectForMask = (element) => {
const boundingRect = getBiggestBoundingClientRect(element);
return boundingRect;
};
export const handleMask = ({ gradientInfo, rect, precomposeRect, tempContext, }) => {
const rectOffsetX = rect.left - precomposeRect.left;
const rectOffsetY = rect.top - precomposeRect.top;
const rectToFill = new DOMRect(rectOffsetX, rectOffsetY, rect.width, rect.height);
const gradient = createCanvasGradient({
ctx: tempContext,
rect: rectToFill,
gradientInfo,
offsetLeft: 0,
offsetTop: 0,
});
tempContext.globalCompositeOperation = 'destination-in';
tempContext.fillStyle = gradient;
tempContext.fillRect(rectToFill.left, rectToFill.top, rectToFill.width, rectToFill.height);
};
@@ -0,0 +1,4 @@
export declare const hasTransformCssValue: (style: CSSStyleDeclaration) => boolean;
export declare const hasRotateCssValue: (style: CSSStyleDeclaration) => boolean;
export declare const hasScaleCssValue: (style: CSSStyleDeclaration) => boolean;
export declare const hasAnyTransformCssValue: (style: CSSStyleDeclaration) => boolean;
@@ -0,0 +1,14 @@
export const hasTransformCssValue = (style) => {
return style.transform !== 'none' && style.transform !== '';
};
export const hasRotateCssValue = (style) => {
return style.rotate !== 'none' && style.rotate !== '';
};
export const hasScaleCssValue = (style) => {
return style.scale !== 'none' && style.scale !== '';
};
export const hasAnyTransformCssValue = (style) => {
return (hasTransformCssValue(style) ||
hasRotateCssValue(style) ||
hasScaleCssValue(style));
};
@@ -0,0 +1,3 @@
import type { LinearGradientInfo } from './parse-linear-gradient';
export declare const getMaskImageValue: (computedStyle: CSSStyleDeclaration) => string | null;
export declare const parseMaskImage: (maskImageValue: string) => LinearGradientInfo | null;
@@ -0,0 +1,14 @@
import { parseLinearGradient } from './parse-linear-gradient';
export const getMaskImageValue = (computedStyle) => {
// Check both standard and webkit-prefixed properties
const { maskImage, webkitMaskImage } = computedStyle;
const value = maskImage || webkitMaskImage;
if (!value || value === 'none') {
return null;
}
return value;
};
export const parseMaskImage = (maskImageValue) => {
// Only linear gradients are supported for now
return parseLinearGradient(maskImageValue);
};
@@ -0,0 +1,4 @@
export declare const setOpacity: ({ ctx, opacity, }: {
ctx: OffscreenCanvasRenderingContext2D;
opacity: number;
}) => () => void;
@@ -0,0 +1,7 @@
export const setOpacity = ({ ctx, opacity, }) => {
const previousAlpha = ctx.globalAlpha;
ctx.globalAlpha = previousAlpha * opacity;
return () => {
ctx.globalAlpha = previousAlpha;
};
};
@@ -0,0 +1,9 @@
import type { BorderRadiusCorners } from './border-radius';
export declare const setOverflowHidden: ({ ctx, rect, borderRadius, overflowHidden, computedStyle, backgroundClip, }: {
ctx: OffscreenCanvasRenderingContext2D;
rect: DOMRect;
borderRadius: BorderRadiusCorners;
overflowHidden: boolean;
computedStyle: CSSStyleDeclaration;
backgroundClip: string;
}) => () => void;
@@ -0,0 +1,14 @@
import { setBorderRadius } from './border-radius';
export const setOverflowHidden = ({ ctx, rect, borderRadius, overflowHidden, computedStyle, backgroundClip, }) => {
if (!overflowHidden) {
return () => { };
}
return setBorderRadius({
ctx,
rect,
borderRadius,
forceClipEvenWhenZero: true,
computedStyle,
backgroundClip,
});
};
@@ -0,0 +1,16 @@
export interface ColorStop {
color: string;
position: number;
}
export interface LinearGradientInfo {
angle: number;
colorStops: ColorStop[];
}
export declare const parseLinearGradient: (backgroundImage: string) => LinearGradientInfo | null;
export declare const createCanvasGradient: ({ ctx, rect, gradientInfo, offsetLeft, offsetTop, }: {
ctx: OffscreenCanvasRenderingContext2D;
rect: DOMRect;
gradientInfo: LinearGradientInfo;
offsetLeft: number;
offsetTop: number;
}) => CanvasGradient;
@@ -0,0 +1,260 @@
import { NoReactInternals } from 'remotion/no-react';
const isValidColor = (color) => {
try {
const result = NoReactInternals.processColor(color);
return result !== null && result !== undefined;
}
catch (_a) {
return false;
}
};
const parseDirection = (directionStr) => {
const trimmed = directionStr.trim().toLowerCase();
// Handle keywords like "to right", "to bottom", etc.
if (trimmed.startsWith('to ')) {
const direction = trimmed.substring(3).trim();
switch (direction) {
case 'top':
return 0;
case 'right':
return 90;
case 'bottom':
return 180;
case 'left':
return 270;
case 'top right':
case 'right top':
return 45;
case 'bottom right':
case 'right bottom':
return 135;
case 'bottom left':
case 'left bottom':
return 225;
case 'top left':
case 'left top':
return 315;
default:
return 180; // Default to bottom
}
}
// Handle angle values: deg, rad, grad, turn
const angleMatch = trimmed.match(/^(-?\d+\.?\d*)(deg|rad|grad|turn)$/);
if (angleMatch) {
const value = parseFloat(angleMatch[1]);
const unit = angleMatch[2];
switch (unit) {
case 'deg':
return value;
case 'rad':
return (value * 180) / Math.PI;
case 'grad':
return (value * 360) / 400;
case 'turn':
return value * 360;
default:
return value;
}
}
// Default: to bottom
return 180;
};
const parseColorStops = (colorStopsStr) => {
// Split by comma, but respect parentheses in rgba(), rgb(), hsl(), hsla()
const parts = colorStopsStr.split(/,(?![^(]*\))/);
const stops = [];
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed)
continue;
// Extract color: can be rgb(), rgba(), hsl(), hsla(), hex, or named color
const colorMatch = trimmed.match(/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-f]{3,8}|[a-z]+)/i);
if (!colorMatch) {
continue;
}
const colorStr = colorMatch[0];
// Validate that this is actually a valid CSS color
if (!isValidColor(colorStr)) {
continue;
}
const remaining = trimmed
.substring(colorMatch.index + colorStr.length)
.trim();
// Canvas API supports CSS colors directly, so we can use the color string as-is
const normalizedColor = colorStr;
// Parse position if provided
let position = null;
if (remaining) {
const posMatch = remaining.match(/(-?\d+\.?\d*)(%|px)?/);
if (posMatch) {
const value = parseFloat(posMatch[1]);
const unit = posMatch[2];
if (unit === '%') {
position = value / 100;
}
else if (unit === 'px') {
// px values need element dimensions, which we don't have here
// We'll handle this as a percentage for now (not fully CSS-compliant but good enough)
position = null;
}
else {
position = value / 100; // Assume percentage if no unit
}
}
}
stops.push({
color: normalizedColor,
position: position !== null ? position : -1, // -1 means needs to be calculated
});
}
if (stops.length === 0) {
return null;
}
// Distribute positions evenly for stops that don't have explicit positions
let lastExplicitIndex = -1;
let lastExplicitPosition = 0;
for (let i = 0; i < stops.length; i++) {
if (stops[i].position !== -1) {
// Found an explicit position
if (lastExplicitIndex >= 0) {
// Interpolate between last explicit and current explicit
const numImplicit = i - lastExplicitIndex - 1;
if (numImplicit > 0) {
const step = (stops[i].position - lastExplicitPosition) / (numImplicit + 1);
for (let j = lastExplicitIndex + 1; j < i; j++) {
stops[j].position =
lastExplicitPosition + step * (j - lastExplicitIndex);
}
}
}
else {
// Backfill from start to first explicit
const numImplicit = i;
if (numImplicit > 0) {
const step = stops[i].position / (numImplicit + 1);
for (let j = 0; j < i; j++) {
stops[j].position = step * (j + 1);
}
}
}
lastExplicitIndex = i;
lastExplicitPosition = stops[i].position;
}
}
// If no explicit positions were provided at all, distribute evenly
// Check this BEFORE handling trailing stops
if (stops.every((s) => s.position === -1)) {
if (stops.length === 1) {
stops[0].position = 0.5;
}
else {
for (let i = 0; i < stops.length; i++) {
stops[i].position = i / (stops.length - 1);
}
}
}
else if (lastExplicitIndex < stops.length - 1) {
const numImplicit = stops.length - 1 - lastExplicitIndex;
const step = (1 - lastExplicitPosition) / (numImplicit + 1);
for (let i = lastExplicitIndex + 1; i < stops.length; i++) {
stops[i].position = lastExplicitPosition + step * (i - lastExplicitIndex);
}
}
// Clamp positions to 0-1
for (const stop of stops) {
stop.position = Math.max(0, Math.min(1, stop.position));
}
return stops;
};
const extractGradientContent = (backgroundImage) => {
const prefix = 'linear-gradient(';
const startIndex = backgroundImage.toLowerCase().indexOf(prefix);
if (startIndex === -1) {
return null;
}
// Find matching closing parenthesis, handling nested parens from rgb(), rgba(), etc.
let depth = 0;
const contentStart = startIndex + prefix.length;
for (let i = contentStart; i < backgroundImage.length; i++) {
const char = backgroundImage[i];
if (char === '(') {
depth++;
}
else if (char === ')') {
if (depth === 0) {
return backgroundImage.substring(contentStart, i).trim();
}
depth--;
}
}
return null;
};
export const parseLinearGradient = (backgroundImage) => {
if (!backgroundImage || backgroundImage === 'none') {
return null;
}
const content = extractGradientContent(backgroundImage);
if (!content) {
return null;
}
// Try to identify the direction/angle part vs color stops
// Direction/angle is optional and comes first
// It can be: "to right", "45deg", etc.
// Split into parts, respecting parentheses
const parts = content.split(/,(?![^(]*\))/);
let angle = 180; // Default: to bottom
let colorStopsStart = 0;
// Check if first part is a direction/angle
if (parts.length > 0) {
const firstPart = parts[0].trim();
// Check if it looks like a direction or angle (not a color)
const isDirection = firstPart.startsWith('to ') ||
/^-?\d+\.?\d*(deg|rad|grad|turn)$/.test(firstPart);
if (isDirection) {
angle = parseDirection(firstPart);
colorStopsStart = 1;
}
}
// Parse color stops
const colorStopsStr = parts.slice(colorStopsStart).join(',');
const colorStops = parseColorStops(colorStopsStr);
if (!colorStops || colorStops.length === 0) {
return null;
}
return {
angle,
colorStops,
};
};
export const createCanvasGradient = ({ ctx, rect, gradientInfo, offsetLeft, offsetTop, }) => {
// Convert angle to radians
// CSS angles: 0deg = to top, 90deg = to right, 180deg = to bottom, 270deg = to left
// We need to calculate the gradient line that spans the rectangle at the given angle
const angleRad = ((gradientInfo.angle - 90) * Math.PI) / 180;
const centerX = rect.left - offsetLeft + rect.width / 2;
const centerY = rect.top - offsetTop + rect.height / 2;
// Calculate gradient line endpoints
// The gradient line passes through the center and has the specified angle
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
// Find the intersection of the gradient line with the rectangle edges
const halfWidth = rect.width / 2;
const halfHeight = rect.height / 2;
// Calculate the length from center to edge along the gradient line.
// Primary formula should always be > 0 for valid angles and non-zero rects;
// fall back to diagonal length only for degenerate or invalid cases.
let length = Math.abs(cos) * halfWidth + Math.abs(sin) * halfHeight;
if (!Number.isFinite(length) || length === 0) {
length = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
}
const x0 = centerX - cos * length;
const y0 = centerY - sin * length;
const x1 = centerX + cos * length;
const y1 = centerY + sin * length;
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
// Add color stops
for (const stop of gradientInfo.colorStops) {
gradient.addColorStop(stop.position, stop.color);
}
return gradient;
};
@@ -0,0 +1,4 @@
export declare const parseTransformOrigin: (transformOrigin: string) => {
x: number;
y: number;
} | null;
@@ -0,0 +1,7 @@
export const parseTransformOrigin = (transformOrigin) => {
if (transformOrigin.trim() === '') {
return null;
}
const [x, y] = transformOrigin.split(' ');
return { x: parseFloat(x), y: parseFloat(y) };
};
@@ -0,0 +1,11 @@
import type { LogLevel } from 'remotion';
import type { InternalState } from '../internal-state';
export declare const precomposeDOMElement: ({ boundingRect, element, logLevel, internalState, }: {
boundingRect: DOMRect;
element: HTMLElement | SVGElement;
logLevel: LogLevel;
internalState: InternalState;
}) => Promise<{
tempCanvas: OffscreenCanvas;
tempContext: OffscreenCanvasRenderingContext2D;
}>;
@@ -0,0 +1,14 @@
import { compose } from '../compose';
export const precomposeDOMElement = async ({ boundingRect, element, logLevel, internalState, }) => {
const tempCanvas = new OffscreenCanvas(boundingRect.width, boundingRect.height);
const tempContext = tempCanvas.getContext('2d');
await compose({
element,
context: tempContext,
logLevel,
parentRect: boundingRect,
internalState,
onlyBackgroundClip: false,
});
return { tempCanvas, tempContext };
};
@@ -0,0 +1,34 @@
import type { DrawFn } from './drawn-fn';
export type ProcessNodeReturnValue = {
type: 'continue';
cleanupAfterChildren: null | (() => void);
} | {
type: 'skip-children';
};
export declare const processNode: ({ element, context, draw, logLevel, parentRect, internalState, rootElement, scale, }: {
element: HTMLElement | SVGElement;
context: OffscreenCanvasRenderingContext2D;
draw: DrawFn;
logLevel: "error" | "info" | "trace" | "verbose" | "warn";
parentRect: DOMRect;
internalState: {
getDrawn3dPixels: () => number;
getPrecomposedTiles: () => number;
addPrecompose: ({ canvasWidth, canvasHeight, }: {
canvasWidth: number;
canvasHeight: number;
}) => void;
helperCanvasState: import("../internal-state").HelperCanvasState;
[Symbol.dispose]: () => void;
getWaitForReadyTime: () => number;
addWaitForReadyTime: (time: number) => void;
getAddSampleTime: () => number;
addAddSampleTime: (time: number) => void;
getCreateFrameTime: () => number;
addCreateFrameTime: (time: number) => void;
getAudioMixingTime: () => number;
addAudioMixingTime: (time: number) => void;
};
rootElement: HTMLElement | SVGElement;
scale: number;
}) => Promise<ProcessNodeReturnValue>;
@@ -0,0 +1,122 @@
import { Internals } from 'remotion';
import { calculateTransforms } from './calculate-transforms';
import { getWiderRectAndExpand } from './clamp-rect-to-parent-bounds';
import { doRectsIntersect } from './do-rects-intersect';
import { drawElement } from './draw-element';
import { getPrecomposeRectFor3DTransform, handle3dTransform, } from './handle-3d-transform';
import { getPrecomposeRectForMask, handleMask } from './handle-mask';
import { precomposeDOMElement } from './precompose';
import { roundToExpandRect } from './round-to-expand-rect';
import { transformDOMRect } from './transform-rect-with-matrix';
export const processNode = async ({ element, context, draw, logLevel, parentRect, internalState, rootElement, }) => {
const { totalMatrix, reset, dimensions, opacity, computedStyle, precompositing, } = calculateTransforms({
element,
rootElement,
});
if (opacity === 0) {
reset();
return { type: 'skip-children' };
}
// When backfaceVisibility is 'hidden', don't render if the element is rotated
// to show its backface. The backface is visible when the z-component of the
// transformed normal vector (0, 0, 1) is negative, which corresponds to m33 < 0.
if (computedStyle.backfaceVisibility === 'hidden' && totalMatrix.m33 < 0) {
reset();
return { type: 'skip-children' };
}
if (dimensions.width <= 0 || dimensions.height <= 0) {
reset();
return { type: 'continue', cleanupAfterChildren: null };
}
const rect = new DOMRect(dimensions.left - parentRect.x, dimensions.top - parentRect.y, dimensions.width, dimensions.height);
if (precompositing.needsPrecompositing) {
const start = Date.now();
let precomposeRect = null;
if (precompositing.needsMaskImage) {
precomposeRect = getWiderRectAndExpand({
firstRect: precomposeRect,
secondRect: getPrecomposeRectForMask(element),
});
}
if (precompositing.needs3DTransformViaWebGL) {
precomposeRect = getWiderRectAndExpand({
firstRect: precomposeRect,
secondRect: getPrecomposeRectFor3DTransform({
element,
parentRect,
matrix: totalMatrix,
}),
});
}
if (!precomposeRect) {
throw new Error('Precompose rect not found');
}
if (precomposeRect.width <= 0 || precomposeRect.height <= 0) {
return { type: 'continue', cleanupAfterChildren: null };
}
if (!doRectsIntersect(precomposeRect, parentRect)) {
return { type: 'continue', cleanupAfterChildren: null };
}
const { tempCanvas, tempContext } = await precomposeDOMElement({
boundingRect: precomposeRect,
element,
logLevel,
internalState,
});
let drawable = tempCanvas;
const rectAfterTransforms = roundToExpandRect(transformDOMRect({
rect: precomposeRect,
matrix: totalMatrix,
}));
if (precompositing.needsMaskImage) {
handleMask({
gradientInfo: precompositing.needsMaskImage,
rect,
precomposeRect,
tempContext,
});
}
if (precompositing.needs3DTransformViaWebGL) {
const t = handle3dTransform({
matrix: totalMatrix,
precomposeRect,
tempCanvas: drawable,
rectAfterTransforms,
internalState,
});
if (t) {
drawable = t;
}
}
const previousTransform = context.getTransform();
if (drawable) {
context.setTransform(new DOMMatrix());
context.drawImage(drawable, 0, drawable.height - rectAfterTransforms.height, rectAfterTransforms.width, rectAfterTransforms.height, rectAfterTransforms.left - parentRect.x, rectAfterTransforms.top - parentRect.y, rectAfterTransforms.width, rectAfterTransforms.height);
context.setTransform(previousTransform);
Internals.Log.trace({
logLevel,
tag: '@remotion/web-renderer',
}, `Transforming element in 3D - canvas size: ${precomposeRect.width}x${precomposeRect.height} - compose: ${Date.now() - start}ms`);
internalState.addPrecompose({
canvasWidth: precomposeRect.width,
canvasHeight: precomposeRect.height,
});
}
reset();
return { type: 'skip-children' };
}
const { cleanupAfterChildren } = await drawElement({
rect,
computedStyle,
context,
draw,
opacity,
totalMatrix,
parentRect,
logLevel,
element,
internalState,
});
reset();
return { type: 'continue', cleanupAfterChildren };
};
@@ -0,0 +1 @@
export declare const roundToExpandRect: (rect: DOMRect) => DOMRect;
@@ -0,0 +1,7 @@
export const roundToExpandRect = (rect) => {
const left = Math.floor(rect.left);
const top = Math.floor(rect.top);
const right = Math.ceil(rect.right);
const bottom = Math.ceil(rect.bottom);
return new DOMRect(left, top, right - left, bottom - top);
};
@@ -0,0 +1,4 @@
export declare const scaleRect: ({ rect, scale, }: {
rect: DOMRect;
scale: number;
}) => DOMRect;
@@ -0,0 +1 @@
export declare const applyTextTransform: (text: string, transform: string) => string;
@@ -0,0 +1,12 @@
export const applyTextTransform = (text, transform) => {
if (transform === 'uppercase') {
return text.toUpperCase();
}
if (transform === 'lowercase') {
return text.toLowerCase();
}
if (transform === 'capitalize') {
return text.replace(/\b\w/g, (char) => char.toUpperCase());
}
return text;
};
@@ -0,0 +1,7 @@
import type { DrawFn } from '../drawn-fn';
export declare const drawText: ({ span, logLevel, onlyBackgroundClipText, parentRect, }: {
span: HTMLSpanElement;
logLevel: "error" | "info" | "trace" | "verbose" | "warn";
parentRect: DOMRect;
onlyBackgroundClipText: boolean;
}) => DrawFn;
@@ -0,0 +1,53 @@
import { Internals } from 'remotion';
import { applyTextTransform } from './apply-text-transform';
import { findLineBreaks } from './find-line-breaks.text';
import { getCollapsedText } from './get-collapsed-text';
export const drawText = ({ span, logLevel, onlyBackgroundClip, }) => {
const drawFn = ({ dimensions: rect, computedStyle, contextToDraw }) => {
const { fontFamily, fontSize, fontWeight, direction, writingMode, letterSpacing, textTransform, webkitTextFillColor, } = computedStyle;
const isVertical = writingMode !== 'horizontal-tb';
if (isVertical) {
// TODO: Only warn once per render.
Internals.Log.warn({
logLevel,
tag: '@remotion/web-renderer',
}, 'Detected "writing-mode" CSS property. Vertical text is not yet supported in @remotion/web-renderer');
return;
}
contextToDraw.save();
const fontSizePx = parseFloat(fontSize);
contextToDraw.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;
contextToDraw.fillStyle =
// If text is being applied with backgroundClipText, we need to use a solid color otherwise it won't get
// applied in canvas
onlyBackgroundClip
? 'black'
: // -webkit-text-fill-color overrides color, and defaults to the value of `color`
webkitTextFillColor;
contextToDraw.letterSpacing = letterSpacing;
const isRTL = direction === 'rtl';
contextToDraw.textAlign = isRTL ? 'right' : 'left';
contextToDraw.textBaseline = 'alphabetic';
const originalText = span.textContent;
const collapsedText = getCollapsedText(span);
const transformedText = applyTextTransform(collapsedText, textTransform);
span.textContent = transformedText;
// For RTL text, fill from the right edge instead of left
const xPosition = isRTL ? rect.right : rect.left;
const lines = findLineBreaks(span, isRTL);
let offsetTop = 0;
const measurements = contextToDraw.measureText(lines[0].text);
const { fontBoundingBoxDescent, fontBoundingBoxAscent } = measurements;
const fontHeight = fontBoundingBoxAscent + fontBoundingBoxDescent;
for (const line of lines) {
// Calculate leading
const leading = line.height - fontHeight;
const halfLeading = leading / 2;
contextToDraw.fillText(line.text, xPosition + line.offsetHorizontal, rect.top + halfLeading + fontBoundingBoxAscent + offsetTop);
offsetTop += line.height;
}
span.textContent = originalText;
contextToDraw.restore();
};
return drawFn;
};
@@ -0,0 +1,6 @@
type Token = {
text: string;
rect: DOMRect;
};
export declare const findWords: (span: HTMLSpanElement) => Token[];
export {};
@@ -0,0 +1,118 @@
import { getCollapsedText } from './get-collapsed-text';
// Punctuation that cannot start a line according to Unicode line breaking rules
// When these would start a line, the browser moves the preceding word to the new line
const cannotStartLine = (segment) => {
if (segment.length === 0)
return false;
const firstChar = segment[0];
const forbiddenLineStarts = [
'.',
',',
';',
':',
'!',
'?',
')',
']',
'}',
'"',
"'",
'"',
`'`,
'»',
'…',
'‥',
'·',
'%',
'‰',
];
return forbiddenLineStarts.includes(firstChar);
};
export function findLineBreaks(span, rtl) {
const textNode = span.childNodes[0];
const originalText = textNode.textContent;
const originalRect = span.getBoundingClientRect();
const computedStyle = getComputedStyle(span);
const segmenter = new Intl.Segmenter('en', { granularity: 'word' });
const segments = segmenter.segment(originalText);
const words = Array.from(segments).map((s) => s.segment);
// If the text would be centered in a flexbox container
const lines = [];
let currentLine = '';
let testText = '';
let previousRect = originalRect;
textNode.textContent = '';
for (let i = 0; i < words.length; i += 1) {
const word = words[i];
testText += word;
let wordsToAdd = word;
while (typeof words[i + 1] !== 'undefined' && words[i + 1].trim() === '') {
testText += words[i + 1];
wordsToAdd += words[i + 1];
i++;
}
previousRect = span.getBoundingClientRect();
textNode.textContent = testText;
const collapsedText = getCollapsedText(span);
textNode.textContent = collapsedText;
const rect = span.getBoundingClientRect();
const currentHeight = rect.height;
// If height changed significantly, we had a line break
if (previousRect &&
previousRect.height !== 0 &&
Math.abs(currentHeight - previousRect.height) > 2) {
const offsetHorizontal = rtl
? previousRect.right - originalRect.right
: previousRect.left - originalRect.left;
const shouldCollapse = !computedStyle.whiteSpaceCollapse.includes('preserve');
let textForPreviousLine = currentLine;
let textForNewLine = wordsToAdd;
// If the segment that triggered the break can't start a line (e.g., punctuation),
// the browser would have moved the preceding word to the new line as well
if (cannotStartLine(word)) {
const currentLineSegments = Array.from(segmenter.segment(currentLine)).map((s) => s.segment);
// Find the last non-whitespace segment (the word to move)
let lastWordIndex = currentLineSegments.length - 1;
while (lastWordIndex >= 0 &&
currentLineSegments[lastWordIndex].trim() === '') {
lastWordIndex--;
}
if (lastWordIndex >= 0) {
// Move the last word (and any trailing whitespace) to the new line
textForPreviousLine = currentLineSegments
.slice(0, lastWordIndex)
.join('');
textForNewLine =
currentLineSegments.slice(lastWordIndex).join('') + wordsToAdd;
}
}
lines.push({
text: shouldCollapse ? textForPreviousLine.trim() : textForPreviousLine,
height: currentHeight - previousRect.height,
offsetHorizontal,
});
currentLine = textForNewLine;
}
else {
currentLine += wordsToAdd;
}
}
// Add the last line
if (currentLine) {
textNode.textContent = testText;
const rects = span.getClientRects();
const rect = span.getBoundingClientRect();
const lastRect = rects[rects.length - 1];
const offsetHorizontal = rtl
? lastRect.right - originalRect.right
: lastRect.left - originalRect.left;
lines.push({
text: currentLine,
height: rect.height - lines.reduce((acc, curr) => acc + curr.height, 0),
offsetHorizontal,
});
}
// Reset to original text
textNode.textContent = originalText;
return lines;
}
@@ -0,0 +1 @@
export declare const getCollapsedText: (span: HTMLSpanElement) => string;
@@ -0,0 +1,46 @@
export const getCollapsedText = (span) => {
const textNode = span.firstChild;
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
throw new Error('Span must contain a single text node');
}
const originalText = textNode.textContent || '';
let collapsedText = originalText;
// Helper to measure width
const measureWidth = (text) => {
textNode.textContent = text;
return span.getBoundingClientRect().width;
};
const originalWidth = measureWidth(originalText);
// Test leading whitespace
if (/^\s/.test(collapsedText)) {
const trimmedLeading = collapsedText.replace(/^\s+/, '');
const newWidth = measureWidth(trimmedLeading);
if (newWidth === originalWidth) {
// Whitespace was collapsed by the browser
collapsedText = trimmedLeading;
}
}
// Test trailing whitespace (on current collapsed text)
if (/\s$/.test(collapsedText)) {
const currentWidth = measureWidth(collapsedText);
const trimmedTrailing = collapsedText.replace(/\s+$/, '');
const newWidth = measureWidth(trimmedTrailing);
if (newWidth === currentWidth) {
// Whitespace was collapsed by the browser
collapsedText = trimmedTrailing;
}
}
// Test internal duplicate whitespace (on current collapsed text)
if (/\s\s/.test(collapsedText)) {
const currentWidth = measureWidth(collapsedText);
const collapsedInternal = collapsedText.replace(/\s\s+/g, ' ');
const newWidth = measureWidth(collapsedInternal);
if (newWidth === currentWidth) {
// Whitespace was collapsed by the browser
collapsedText = collapsedInternal;
}
}
// Restore original text
textNode.textContent = originalText;
return collapsedText;
};
@@ -0,0 +1,28 @@
import type { ProcessNodeReturnValue } from '../process-node';
export declare const handleTextNode: ({ node, context, logLevel, parentRect, internalState, rootElement, onlyBackgroundClipText, scale, }: {
node: Text;
context: OffscreenCanvasRenderingContext2D;
logLevel: "error" | "info" | "trace" | "verbose" | "warn";
parentRect: DOMRect;
internalState: {
getDrawn3dPixels: () => number;
getPrecomposedTiles: () => number;
addPrecompose: ({ canvasWidth, canvasHeight, }: {
canvasWidth: number;
canvasHeight: number;
}) => void;
helperCanvasState: import("../../internal-state").HelperCanvasState;
[Symbol.dispose]: () => void;
getWaitForReadyTime: () => number;
addWaitForReadyTime: (time: number) => void;
getAddSampleTime: () => number;
addAddSampleTime: (time: number) => void;
getCreateFrameTime: () => number;
addCreateFrameTime: (time: number) => void;
getAudioMixingTime: () => number;
addAudioMixingTime: (time: number) => void;
};
rootElement: HTMLElement | SVGElement;
onlyBackgroundClipText: boolean;
scale: number;
}) => Promise<ProcessNodeReturnValue>;
@@ -0,0 +1,24 @@
import { processNode } from '../process-node';
import { drawText } from './draw-text';
export const handleTextNode = async ({ node, context, logLevel, parentRect, internalState, rootElement, onlyBackgroundClip, }) => {
const span = document.createElement('span');
const parent = node.parentNode;
if (!parent) {
throw new Error('Text node has no parent');
}
parent.insertBefore(span, node);
span.appendChild(node);
const value = await processNode({
context,
element: span,
draw: drawText({ span, logLevel, onlyBackgroundClip }),
logLevel,
parentRect,
internalState,
rootElement,
});
// Undo the layout manipulation
parent.insertBefore(node, span);
parent.removeChild(span);
return value;
};
@@ -0,0 +1,26 @@
import type { HelperCanvasState } from '../internal-state';
export declare const transformIn3d: ({ matrix, sourceCanvas, sourceRect, destRect, internalState, scale, }: {
sourceRect: DOMRect;
matrix: DOMMatrix;
sourceCanvas: OffscreenCanvas;
destRect: DOMRect;
internalState: {
getDrawn3dPixels: () => number;
getPrecomposedTiles: () => number;
addPrecompose: ({ canvasWidth, canvasHeight, }: {
canvasWidth: number;
canvasHeight: number;
}) => void;
helperCanvasState: HelperCanvasState;
[Symbol.dispose]: () => void;
getWaitForReadyTime: () => number;
addWaitForReadyTime: (time: number) => void;
getAddSampleTime: () => number;
addAddSampleTime: (time: number) => void;
getCreateFrameTime: () => number;
addCreateFrameTime: (time: number) => void;
getAudioMixingTime: () => number;
addAudioMixingTime: (time: number) => void;
};
scale: number;
}) => OffscreenCanvas;
@@ -0,0 +1,177 @@
// Vertex shader - now includes texture coordinates
const vsSource = `
attribute vec2 aPosition;
attribute vec2 aTexCoord;
uniform mat4 uTransform;
uniform mat4 uProjection;
varying vec2 vTexCoord;
void main() {
gl_Position = uProjection * uTransform * vec4(aPosition, 0.0, 1.0);
vTexCoord = aTexCoord;
}
`;
// Fragment shader - samples from texture and unpremultiplies alpha
const fsSource = `
precision mediump float;
uniform sampler2D uTexture;
varying vec2 vTexCoord;
void main() {
gl_FragColor = texture2D(uTexture, vTexCoord);
}
`;
function compileShader(shaderGl, source, type) {
const shader = shaderGl.createShader(type);
if (!shader) {
throw new Error('Could not create shader');
}
shaderGl.shaderSource(shader, source);
shaderGl.compileShader(shader);
if (!shaderGl.getShaderParameter(shader, shaderGl.COMPILE_STATUS)) {
const log = shaderGl.getShaderInfoLog(shader);
shaderGl.deleteShader(shader);
throw new Error('Shader compile error: ' + log);
}
return shader;
}
const createHelperCanvas = ({ canvasWidth, canvasHeight, helperCanvasState, }) => {
var _a;
if (helperCanvasState.current) {
// Resize canvas if dimensions changed
if (helperCanvasState.current.canvas.width !== canvasWidth ||
helperCanvasState.current.canvas.height !== canvasHeight) {
helperCanvasState.current.canvas.width = canvasWidth;
helperCanvasState.current.canvas.height = canvasHeight;
}
// Always reset viewport and clear when reusing
helperCanvasState.current.gl.viewport(0, 0, canvasWidth, canvasHeight);
helperCanvasState.current.gl.clearColor(0, 0, 0, 0);
helperCanvasState.current.gl.clear(helperCanvasState.current.gl.COLOR_BUFFER_BIT);
return helperCanvasState.current;
}
const canvas = new OffscreenCanvas(canvasWidth, canvasHeight);
const gl = (_a = canvas.getContext('webgl', {
premultipliedAlpha: true,
})) !== null && _a !== void 0 ? _a : undefined;
if (!gl) {
throw new Error('WebGL not supported');
}
// Compile shaders and create program once
const vertexShader = compileShader(gl, vsSource, gl.VERTEX_SHADER);
const fragmentShader = compileShader(gl, fsSource, gl.FRAGMENT_SHADER);
const program = gl.createProgram();
if (!program) {
throw new Error('Could not create program');
}
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error('Program link error: ' + gl.getProgramInfoLog(program));
}
// Get attribute and uniform locations once
const locations = {
aPosition: gl.getAttribLocation(program, 'aPosition'),
aTexCoord: gl.getAttribLocation(program, 'aTexCoord'),
uTransform: gl.getUniformLocation(program, 'uTransform'),
uProjection: gl.getUniformLocation(program, 'uProjection'),
uTexture: gl.getUniformLocation(program, 'uTexture'),
};
// Shaders can be deleted after linking
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
const cleanup = () => {
gl.deleteProgram(program);
const loseContext = gl.getExtension('WEBGL_lose_context');
if (loseContext) {
loseContext.loseContext();
}
};
helperCanvasState.current = { canvas, gl, program, locations, cleanup };
return helperCanvasState.current;
};
export const transformIn3d = ({ matrix, sourceCanvas, untransformedRect, rectAfterTransforms, internalState, }) => {
const { canvas, gl, program, locations } = createHelperCanvas({
canvasWidth: rectAfterTransforms.width,
canvasHeight: rectAfterTransforms.height,
helperCanvasState: internalState.helperCanvasState,
});
// Use the cached program
gl.useProgram(program);
// Setup viewport and clear (already done in createHelperCanvas, but ensure it's set)
gl.viewport(0, 0, rectAfterTransforms.width, rectAfterTransforms.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Enable blending
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
// Create vertex buffer
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// prettier-ignore
const vertices = new Float32Array([
untransformedRect.x, untransformedRect.y, 0, 0,
untransformedRect.x + untransformedRect.width, untransformedRect.y, 1, 0,
untransformedRect.x, untransformedRect.y + untransformedRect.height, 0, 1,
untransformedRect.x, untransformedRect.y + untransformedRect.height, 0, 1,
untransformedRect.x + untransformedRect.width, untransformedRect.y, 1, 0,
untransformedRect.x + untransformedRect.width, untransformedRect.y + untransformedRect.height, 1, 1,
]);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// Setup attributes using cached locations
gl.enableVertexAttribArray(locations.aPosition);
gl.vertexAttribPointer(locations.aPosition, 2, gl.FLOAT, false, 4 * 4, 0);
gl.enableVertexAttribArray(locations.aTexCoord);
gl.vertexAttribPointer(locations.aTexCoord, 2, gl.FLOAT, false, 4 * 4, 2 * 4);
// Create and configure texture
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, sourceCanvas);
// Set uniforms using cached locations
const transformMatrix = matrix.toFloat32Array();
const zScale = 1000000000;
// Projection matrix accounts for the output canvas dimensions
const projectionMatrix = new Float32Array([
2 / rectAfterTransforms.width,
0,
0,
0,
0,
-2 / rectAfterTransforms.height,
0,
0,
0,
0,
-2 / zScale,
0,
-1 + (2 * -rectAfterTransforms.x) / rectAfterTransforms.width,
1 - (2 * -rectAfterTransforms.y) / rectAfterTransforms.height,
0,
1,
]);
gl.uniformMatrix4fv(locations.uTransform, false, transformMatrix);
gl.uniformMatrix4fv(locations.uProjection, false, projectionMatrix);
gl.uniform1i(locations.uTexture, 0);
// Draw
gl.drawArrays(gl.TRIANGLES, 0, 6);
// Clean up per-frame resources only
gl.disableVertexAttribArray(locations.aPosition);
gl.disableVertexAttribArray(locations.aTexCoord);
gl.deleteTexture(texture);
gl.deleteBuffer(vertexBuffer);
gl.bindTexture(gl.TEXTURE_2D, null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// Reset state
gl.disable(gl.BLEND);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
return {
canvas,
rect: rectAfterTransforms,
};
};
@@ -0,0 +1,4 @@
export declare function transformDOMRect({ rect, matrix }: {
rect: DOMRect;
matrix: DOMMatrix;
}): DOMRect;
@@ -0,0 +1,19 @@
export function transformDOMRect({ rect, matrix, }) {
// Get all four corners of the rectangle
const topLeft = new DOMPointReadOnly(rect.left, rect.top);
const topRight = new DOMPointReadOnly(rect.right, rect.top);
const bottomLeft = new DOMPointReadOnly(rect.left, rect.bottom);
const bottomRight = new DOMPointReadOnly(rect.right, rect.bottom);
// Transform all corners
const transformedTopLeft = topLeft.matrixTransform(matrix);
const transformedTopRight = topRight.matrixTransform(matrix);
const transformedBottomLeft = bottomLeft.matrixTransform(matrix);
const transformedBottomRight = bottomRight.matrixTransform(matrix);
// Find the bounding box of the transformed points
const minX = Math.min(transformedTopLeft.x / transformedTopLeft.w, transformedTopRight.x / transformedTopRight.w, transformedBottomLeft.x / transformedBottomLeft.w, transformedBottomRight.x / transformedBottomRight.w);
const maxX = Math.max(transformedTopLeft.x / transformedTopLeft.w, transformedTopRight.x / transformedTopRight.w, transformedBottomLeft.x / transformedBottomLeft.w, transformedBottomRight.x / transformedBottomRight.w);
const minY = Math.min(transformedTopLeft.y / transformedTopLeft.w, transformedTopRight.y / transformedTopRight.w, transformedBottomLeft.y / transformedBottomLeft.w, transformedBottomRight.y / transformedBottomRight.w);
const maxY = Math.max(transformedTopLeft.y / transformedTopLeft.w, transformedTopRight.y / transformedTopRight.w, transformedBottomLeft.y / transformedBottomLeft.w, transformedBottomRight.y / transformedBottomRight.w);
// Create a new DOMRect from the bounding box
return new DOMRect(minX, minY, maxX - minX, maxY - minY);
}
@@ -0,0 +1,6 @@
export declare const setTransform: ({ ctx, transform, parentRect, scale, }: {
ctx: OffscreenCanvasRenderingContext2D;
transform: DOMMatrix;
parentRect: DOMRect;
scale: number;
}) => () => void;
@@ -0,0 +1,10 @@
export const setTransform = ({ ctx, transform, parentRect, }) => {
const offsetMatrix = new DOMMatrix()
.translate(-parentRect.x, -parentRect.y)
.multiply(transform)
.translate(parentRect.x, parentRect.y);
ctx.setTransform(offsetMatrix);
return () => {
ctx.setTransform(new DOMMatrix());
};
};
@@ -0,0 +1 @@
export declare const turnSvgIntoDrawable: (svg: SVGSVGElement) => Promise<HTMLImageElement>;
@@ -0,0 +1,41 @@
export const turnSvgIntoDrawable = (svg) => {
const { fill, color } = getComputedStyle(svg);
const originalTransform = svg.style.transform;
const originalTransformOrigin = svg.style.transformOrigin;
const originalMarginLeft = svg.style.marginLeft;
const originalMarginRight = svg.style.marginRight;
const originalMarginTop = svg.style.marginTop;
const originalMarginBottom = svg.style.marginBottom;
const originalFill = svg.style.fill;
const originalColor = svg.style.color;
svg.style.transform = 'none';
svg.style.transformOrigin = '';
// Margins were already included in the positioning calculation,
// so we need to remove them to avoid double counting.
svg.style.marginLeft = '0';
svg.style.marginRight = '0';
svg.style.marginTop = '0';
svg.style.marginBottom = '0';
svg.style.fill = fill;
svg.style.color = color;
const svgData = new XMLSerializer().serializeToString(svg);
svg.style.marginLeft = originalMarginLeft;
svg.style.marginRight = originalMarginRight;
svg.style.marginTop = originalMarginTop;
svg.style.marginBottom = originalMarginBottom;
svg.style.transform = originalTransform;
svg.style.transformOrigin = originalTransformOrigin;
svg.style.fill = originalFill;
svg.style.color = originalColor;
return new Promise((resolve, reject) => {
const image = new Image();
const url = `data:image/svg+xml;base64,${btoa(svgData)}`;
image.onload = function () {
resolve(image);
};
image.onerror = () => {
reject(new Error('Failed to convert SVG to image'));
};
image.src = url;
});
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
export type FrameRange = number | [number, number] | [number, null];
export declare const getRealFrameRange: (durationInFrames: number, frameRange: FrameRange | null) => [number, number];
@@ -0,0 +1,15 @@
export const getRealFrameRange = (durationInFrames, frameRange) => {
if (frameRange === null) {
return [0, durationInFrames - 1];
}
if (typeof frameRange === 'number') {
if (frameRange < 0 || frameRange >= durationInFrames) {
throw new Error(`Frame number is out of range, must be between 0 and ${durationInFrames - 1} but got ${frameRange}`);
}
return [frameRange, frameRange];
}
if (frameRange[1] >= durationInFrames || frameRange[0] < 0) {
throw new Error(`The "durationInFrames" of the composition was evaluated to be ${durationInFrames}, but frame range ${frameRange.join('-')} is not inbetween 0-${durationInFrames - 1}`);
}
return frameRange;
};
@@ -0,0 +1,2 @@
import { type AudioEncodingConfig } from 'mediabunny';
export declare const getDefaultAudioEncodingConfig: () => Promise<AudioEncodingConfig | null>;
@@ -0,0 +1,18 @@
import { canEncodeAudio, QUALITY_MEDIUM, } from 'mediabunny';
export const getDefaultAudioEncodingConfig = async () => {
const preferredDefaultAudioEncodingConfig = {
codec: 'aac',
bitrate: QUALITY_MEDIUM,
};
if (await canEncodeAudio(preferredDefaultAudioEncodingConfig.codec, preferredDefaultAudioEncodingConfig)) {
return preferredDefaultAudioEncodingConfig;
}
const backupDefaultAudioEncodingConfig = {
codec: 'opus',
bitrate: QUALITY_MEDIUM,
};
if (await canEncodeAudio(backupDefaultAudioEncodingConfig.codec, backupDefaultAudioEncodingConfig)) {
return backupDefaultAudioEncodingConfig;
}
return null;
};
@@ -0,0 +1 @@
export declare const getBiggestBoundingClientRect: (element: HTMLElement | SVGElement) => DOMRect;

Some files were not shown because too many files have changed in this diff Show More