diff --git a/packages/core/src/HtmlInCanvas.tsx b/packages/core/src/HtmlInCanvas.tsx index 5115096d8e5..f6752fa647f 100644 --- a/packages/core/src/HtmlInCanvas.tsx +++ b/packages/core/src/HtmlInCanvas.tsx @@ -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 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( + null, +); type HtmlInCanvasContentProps = { readonly width: number; @@ -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(); @@ -351,6 +358,8 @@ const HtmlInCanvasContent = forwardRef< const initializedRef = useRef(false); const onInitCleanupRef = useRef(null); const unmountedRef = useRef(false); + const ancestorRef = useRef(ancestor); + ancestorRef.current = ancestor; const onPaintCb = useCallback(async () => { const element = divRef.current; @@ -434,6 +443,11 @@ const HtmlInCanvasContent = forwardRef< height, }); + // Nested : 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); @@ -512,14 +526,19 @@ const HtmlInCanvasContent = forwardRef< }; }, [width, height]); - if (isInsideAncestorHtmlInCanvas) { - throw new Error( - ' 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 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(() => { + return { + requestParentPaint: () => { + canvas2dRef.current?.requestPaint?.(); + }, + }; + }, []); return ( - + ` inside another `` 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 `` inside another `` is supported from . +The inner canvas paints to its own `OffscreenCanvas` and notifies the outer canvas so the next paint captures the freshly composited bitmap. ## Rendering @@ -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 [``](/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 [``](/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: @@ -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 [``](/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 [``](/docs/remotion/html-in-canvas) elements from because nested HTML-in-canvas captures are handled automatically. ## See also diff --git a/packages/docs/docs/remotion/html-in-canvas.mdx b/packages/docs/docs/remotion/html-in-canvas.mdx index 0ddc71d4c70..99c9b3dc531 100644 --- a/packages/docs/docs/remotion/html-in-canvas.mdx +++ b/packages/docs/docs/remotion/html-in-canvas.mdx @@ -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 `
` with the given `width` and `height`. -Do not nest `` inside another ``. Nesting is not supported and throws: - -``` - effects cannot be nested together. Chrome will only display the outer effect. Consider merging the effects into one if you can. -``` +Nesting `` inside another `` is supported from . +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?` diff --git a/packages/example/src/HtmlInCanvas/index.tsx b/packages/example/src/HtmlInCanvas/index.tsx index 2133dc0f9ad..dcb36ecc6d0 100644 --- a/packages/example/src/HtmlInCanvas/index.tsx +++ b/packages/example/src/HtmlInCanvas/index.tsx @@ -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'; diff --git a/packages/example/src/HtmlInCanvas/nested-effects.tsx b/packages/example/src/HtmlInCanvas/nested-effects.tsx new file mode 100644 index 00000000000..294de0d6fd3 --- /dev/null +++ b/packages/example/src/HtmlInCanvas/nested-effects.tsx @@ -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 ( + + + + + + + B + + + + + + + ); +}; diff --git a/packages/example/src/Root.tsx b/packages/example/src/Root.tsx index f7e71a106fe..366909273de 100644 --- a/packages/example/src/Root.tsx +++ b/packages/example/src/Root.tsx @@ -67,6 +67,7 @@ import { HtmlInCanvasDocsDemo2DBlur, HtmlInCanvasDocsMinimalWebGL, HtmlInCanvasDocsMinimalWebGPU, + HtmlInCanvasNestedEffects, HtmlInCanvasPrivacy, HtmlInCanvasReactSvg, LinearBlurTransitionDoc, @@ -1033,6 +1034,14 @@ export const Index: React.FC = () => { width={1920} durationInFrames={120} /> +