Skip to content
39 changes: 29 additions & 10 deletions packages/core/src/HtmlInCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,16 @@ export type HtmlInCanvasProps = Omit<
};
/* eslint-enable react/require-default-props */

const HtmlInCanvasAncestorContext = createContext(false);
type HtmlInCanvasAncestor = {
readonly requestParentPaint: () => void;
};

// When a nested <HtmlInCanvas> sits inside another one, the inner canvas
// must notify the outer canvas after each paint so the outer captures the
// already-painted nested bitmap (not a stale frame).
const HtmlInCanvasAncestorContext = createContext<HtmlInCanvasAncestor | null>(
null,
);

type HtmlInCanvasContentProps = {
readonly width: number;
Expand All @@ -305,9 +314,7 @@ const HtmlInCanvasContent = forwardRef<
{width, height, effects, children, onPaint, onInit, controls, style},
ref,
) => {
const isInsideAncestorHtmlInCanvas = useContext(
HtmlInCanvasAncestorContext,
);
const ancestor = useContext(HtmlInCanvasAncestorContext);

assertHtmlInCanvasDimensions(width, height);
const {continueRender, cancelRender} = useDelayRender();
Expand Down Expand Up @@ -351,6 +358,8 @@ const HtmlInCanvasContent = forwardRef<
const initializedRef = useRef(false);
const onInitCleanupRef = useRef<HtmlInCanvasOnInitCleanup | null>(null);
const unmountedRef = useRef(false);
const ancestorRef = useRef(ancestor);
ancestorRef.current = ancestor;

const onPaintCb = useCallback(async () => {
const element = divRef.current;
Expand Down Expand Up @@ -434,6 +443,11 @@ const HtmlInCanvasContent = forwardRef<
height,
});

// Nested <HtmlInCanvas>: tell the outer canvas to re-paint, so that
// the parent captures this freshly-painted bitmap instead of a stale
// (or empty) one. The parent will skip work if nothing changed.
ancestorRef.current?.requestParentPaint();

continueRender(handle);
} catch (error) {
cancelRender(error);
Expand Down Expand Up @@ -512,14 +526,19 @@ const HtmlInCanvasContent = forwardRef<
};
}, [width, height]);

if (isInsideAncestorHtmlInCanvas) {
throw new Error(
'<HtmlInCanvas> effects cannot be nested together. Chrome will only display the outer effect. Consider merging the effects into one if you can.',
);
}
// Context value for descendant <HtmlInCanvas> instances: when they finish
// painting, ask this canvas to repaint so the new pixels make it onto our
// offscreen surface on the next paint event.
const ancestorValue = useMemo<HtmlInCanvasAncestor>(() => {
return {
requestParentPaint: () => {
canvas2dRef.current?.requestPaint?.();
},
};
}, []);

return (
<HtmlInCanvasAncestorContext.Provider value>
<HtmlInCanvasAncestorContext.Provider value={ancestorValue}>
<canvas
key={canvasSizeKey}
ref={setLayoutCanvasRef}
Expand Down
8 changes: 4 additions & 4 deletions packages/docs/docs/html-in-canvas-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ You can also build your own — see [Making a custom HTML-in-canvas presentation
HTML-in-canvas is only available in Chrome 149 and later with the `chrome://flags/#canvas-draw-element` flag enabled.
The API is unstable - Chrome may change the API or even remove it in the future.

Nesting `<HtmlInCanvas>` inside another `<HtmlInCanvas>` is not supported. Chrome would only display the outer effect, so this is invalid in Remotion. It will throw an error.
If you need to combine multiple effects, try to merge them into a single `onPaint` callback.
Nesting `<HtmlInCanvas>` inside another `<HtmlInCanvas>` is supported from <AvailableFrom v="4.0.461" inline />.
The inner canvas paints to its own `OffscreenCanvas` and notifies the outer canvas so the next paint captures the freshly composited bitmap.

## Rendering

Expand All @@ -120,7 +120,7 @@ If your machine has no GPU, we recommend `--gl=swangle` instead (default on Lamb

## AI agents

Remotion publishes [Agent Skills](/docs/ai/skills) that teach AI agents like Claude Code, Codex and Cursor how to use [`<HtmlInCanvas>`](/docs/remotion/html-in-canvas) correctly — including the [`onInit`](/docs/remotion/html-in-canvas#oninit) / [`onPaint`](/docs/remotion/html-in-canvas#onpaint) lifecycle, the [`--gl=angle` flag](#rendering) and the [no-nesting rule](#limitations).
Remotion publishes [Agent Skills](/docs/ai/skills) that teach AI agents like Claude Code, Codex and Cursor how to use [`<HtmlInCanvas>`](/docs/remotion/html-in-canvas) correctly — including the [`onInit`](/docs/remotion/html-in-canvas#oninit) / [`onPaint`](/docs/remotion/html-in-canvas#onpaint) lifecycle, the [`--gl=angle` flag](#rendering) and nested HTML-in-canvas behavior.

Install them with:

Expand All @@ -134,7 +134,7 @@ Not directly related, but also leveraging the same technology is the [HTML-in-ca

When enabled, the [client-side renderer](/docs/client-side-rendering) uses HTML-in-canvas to capture full frames in the browser, which can produce more accurate output than the default DOM compositor.

This mode cannot render compositions that contain [`<HtmlInCanvas>`](/docs/remotion/html-in-canvas) elements, because doing so would [nest HTML-in-canvas](#limitations) captures, which Chrome does not support.
This mode can render compositions that contain [`<HtmlInCanvas>`](/docs/remotion/html-in-canvas) elements from <AvailableFrom v="4.0.461" inline /> because nested HTML-in-canvas captures are handled automatically.

## See also

Expand Down
7 changes: 2 additions & 5 deletions packages/docs/docs/remotion/html-in-canvas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,8 @@ Height of the canvas and the inner layout area, in pixels. Must be a positive in
Children to draw to the canvas.
Children will be wrapped in a `<div>` with the given `width` and `height`.

Do not nest `<HtmlInCanvas>` inside another `<HtmlInCanvas>`. Nesting is not supported and throws:

```
<HtmlInCanvas> effects cannot be nested together. Chrome will only display the outer effect. Consider merging the effects into one if you can.
```
Nesting `<HtmlInCanvas>` inside another `<HtmlInCanvas>` is supported from <AvailableFrom v="4.0.461" inline />.
The inner canvas paints to its own `OffscreenCanvas` and then asks the outer canvas to repaint so it captures the freshly composited bitmap on the next frame.

### `onPaint?`

Expand Down
1 change: 1 addition & 0 deletions packages/example/src/HtmlInCanvas/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export {
} from './linear-blur-doc';
export {HtmlInCanvasDocsMinimalWebGL} from './minimal-docs-webgl';
export {HtmlInCanvasDocsMinimalWebGPU} from './minimal-docs-webgpu';
export {HtmlInCanvasNestedEffects} from './nested-effects';
export {HtmlInCanvasPrivacy} from './privacy';
export {HtmlInCanvasReactSvg} from './react-svg';
export {RippleTransitionDoc, RippleTransitionDocThumb} from './ripple-doc';
Expand Down
96 changes: 96 additions & 0 deletions packages/example/src/HtmlInCanvas/nested-effects.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, {useCallback} from 'react';
import {
AbsoluteFill,
HtmlInCanvas,
type HtmlInCanvasOnPaint,
useCurrentFrame,
useVideoConfig,
} from 'remotion';

const paintBlur: HtmlInCanvasOnPaint = ({canvas, element, elementImage}) => {
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to acquire 2D context');
}
ctx.reset();
ctx.filter = 'blur(6px)';
const transform = ctx.drawElementImage(elementImage, 0, 0);
element.style.transform = transform.toString();
};

export const HtmlInCanvasNestedEffects: React.FC = () => {
const frame = useCurrentFrame();
const {width, height} = useVideoConfig();

const paintRotation: HtmlInCanvasOnPaint = useCallback(
({canvas, element, elementImage}) => {
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to acquire 2D context');
}
ctx.reset();
const angle = (frame / 60) * Math.PI * 2;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(angle);
ctx.translate(-canvas.width / 2, -canvas.height / 2);
const transform = ctx.drawElementImage(elementImage, 0, 0);
element.style.transform = transform.toString();
},
[frame],
);

const paintTint: HtmlInCanvasOnPaint = useCallback(
({canvas, element, elementImage}) => {
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to acquire 2D context');
}
ctx.reset();
const transform = ctx.drawElementImage(elementImage, 0, 0);
element.style.transform = transform.toString();
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = '#00aaff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = 'source-over';
},
[],
);

return (
<HtmlInCanvas width={width} height={height} onPaint={paintTint}>
<AbsoluteFill
style={{
backgroundColor: 'white',
justifyContent: 'center',
alignItems: 'center',
}}
>
<HtmlInCanvas width={200} height={200} onPaint={paintRotation}>
<AbsoluteFill
style={{
backgroundColor: '#ffcc00',
justifyContent: 'center',
alignItems: 'center',
}}
>
<HtmlInCanvas width={100} height={100} onPaint={paintBlur}>
<AbsoluteFill
style={{
backgroundColor: '#ff0044',
justifyContent: 'center',
alignItems: 'center',
color: 'white',
fontFamily: 'sans-serif',
fontSize: 48,
fontWeight: 700,
}}
>
B
</AbsoluteFill>
</HtmlInCanvas>
</AbsoluteFill>
</HtmlInCanvas>
</AbsoluteFill>
</HtmlInCanvas>
);
};
9 changes: 9 additions & 0 deletions packages/example/src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
HtmlInCanvasDocsDemo2DBlur,
HtmlInCanvasDocsMinimalWebGL,
HtmlInCanvasDocsMinimalWebGPU,
HtmlInCanvasNestedEffects,
HtmlInCanvasPrivacy,
HtmlInCanvasReactSvg,
LinearBlurTransitionDoc,
Expand Down Expand Up @@ -1033,6 +1034,14 @@ export const Index: React.FC = () => {
width={1920}
durationInFrames={120}
/>
<Composition
id="html-in-canvas-nested-effects"
component={HtmlInCanvasNestedEffects}
fps={30}
height={1080}
width={1920}
durationInFrames={120}
/>
<Composition
id="book-flip-transition-doc"
component={BookFlipTransitionDoc}
Expand Down
Loading