remotion
Version:
Make videos programmatically
416 lines (415 loc) • 19.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.HtmlInCanvas = exports.htmlInCanvasSchema = exports.HTML_IN_CANVAS_UNSUPPORTED_MESSAGE = exports.isHtmlInCanvasSupported = void 0;
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = require("react");
const run_effect_chain_js_1 = require("./effects/run-effect-chain.js");
const use_effect_chain_state_js_1 = require("./effects/use-effect-chain-state.js");
const use_memoized_effects_js_1 = require("./effects/use-memoized-effects.js");
const enable_sequence_stack_traces_js_1 = require("./enable-sequence-stack-traces.js");
const interactivity_schema_js_1 = require("./interactivity-schema.js");
const Sequence_js_1 = require("./Sequence.js");
const use_crop_style_js_1 = require("./use-crop-style.js");
const use_delay_render_js_1 = require("./use-delay-render.js");
const use_remotion_environment_js_1 = require("./use-remotion-environment.js");
const with_interactivity_schema_js_1 = require("./with-interactivity-schema.js");
// `transferControlToOffscreen()` may only be called once per canvas — a second
// call throws an `InvalidStateError`. The layout effect that needs the
// `OffscreenCanvas` can run more than once for the same canvas element: its
// dependencies (e.g. the paint callback) can change identity without the
// canvas remounting, and React StrictMode intentionally double-invokes
// effects. Cache the transferred `OffscreenCanvas` per canvas element so
// re-runs reuse it instead of throwing.
const transferredOffscreenCanvases = new WeakMap();
const getTransferredOffscreenCanvas = (canvas) => {
const existing = transferredOffscreenCanvases.get(canvas);
if (existing) {
return existing;
}
const offscreen = canvas.transferControlToOffscreen();
transferredOffscreenCanvases.set(canvas, offscreen);
return offscreen;
};
// Memoize the support check across the session — neither the platform
// capability nor the chrome://flags toggle can change between calls.
// SSR results are not cached so the check runs again once `document` exists.
let cachedSupport = null;
const isHtmlInCanvasSupported = () => {
if (cachedSupport !== null) {
return cachedSupport;
}
if (typeof document === 'undefined') {
return false;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
cachedSupport =
typeof (ctx === null || ctx === void 0 ? void 0 : ctx.drawElementImage) === 'function' &&
typeof canvas.requestPaint === 'function' &&
typeof canvas.captureElementImage === 'function' &&
'transferControlToOffscreen' in HTMLCanvasElement.prototype;
return cachedSupport;
};
exports.isHtmlInCanvasSupported = isHtmlInCanvasSupported;
/** Shown when {@link isHtmlInCanvasSupported} is false: APIs are absent (old Chrome and/or flag off). */
exports.HTML_IN_CANVAS_UNSUPPORTED_MESSAGE = 'HTML in Canvas is not supported. Two common causes: Chrome is older than version 148 (update Chrome), or the HTML-in-Canvas flag is disabled at chrome://flags/#canvas-draw-element (enable it and restart Chrome).';
function assertHtmlInCanvasDimensions(width, height) {
if (typeof width !== 'number' || typeof height !== 'number') {
throw new Error(`HtmlInCanvas: \`width\` and \`height\` must be numbers. Received width=${String(width)}, height=${String(height)}.`);
}
if (!Number.isInteger(width) || width <= 0) {
throw new Error(`HtmlInCanvas: \`width\` must be a positive integer. Received: ${String(width)}.`);
}
if (!Number.isInteger(height) || height <= 0) {
throw new Error(`HtmlInCanvas: \`height\` must be a positive integer. Received: ${String(height)}.`);
}
}
function resolveHtmlInCanvasPixelDensity(pixelDensity) {
if (pixelDensity === undefined) {
return 1;
}
if (typeof pixelDensity !== 'number' ||
!Number.isFinite(pixelDensity) ||
pixelDensity <= 0) {
throw new Error(`HtmlInCanvas: \`pixelDensity\` must be a positive finite number. Received: ${String(pixelDensity)}.`);
}
return pixelDensity;
}
const isMissingPaintRecordError = (error) => {
return error instanceof DOMException && error.name === 'InvalidStateError';
};
const missingPaintRecordMessage = 'HtmlInCanvas: Expected the element to be inside the viewport during rendering, but Chrome had no cached paint record for it.';
const resizePaintTarget = ({ target, width, height, }) => {
if (target.width !== width) {
target.width = width;
}
if (target.height !== height) {
target.height = height;
}
};
const defaultOnPaint = ({ canvas, element, elementImage, }) => {
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to acquire 2D context for <HtmlInCanvas> canvas');
}
ctx.reset();
const transform = ctx.drawElementImage(elementImage, 0, 0);
element.style.transform = transform.toString();
};
/* eslint-enable react/require-default-props */
const HtmlInCanvasAncestorContext = (0, react_1.createContext)(false);
const HtmlInCanvasContent = (0, react_1.forwardRef)(({ width, height, effects, children, onPaint, onInit, pixelDensity, controls, style, }, ref) => {
var _a;
const isInsideAncestorHtmlInCanvas = (0, react_1.useContext)(HtmlInCanvasAncestorContext);
assertHtmlInCanvasDimensions(width, height);
if (isInsideAncestorHtmlInCanvas) {
throw new Error('<HtmlInCanvas> components cannot be nested. Chrome does not reliably render nested HTML-in-canvas subtrees. Consider merging the effects into one <HtmlInCanvas> if you can.');
}
const resolvedPixelDensity = resolveHtmlInCanvasPixelDensity(pixelDensity);
const canvasWidth = Math.ceil(width * resolvedPixelDensity);
const canvasHeight = Math.ceil(height * resolvedPixelDensity);
const { delayRender, continueRender, cancelRender } = (0, use_delay_render_js_1.useDelayRender)();
const { isClientSideRendering, isRendering } = (0, use_remotion_environment_js_1.useRemotionEnvironment)();
const canRetryMissingPaintRecord = !isRendering || isClientSideRendering;
const usesDirectLayoutCanvas = onPaint === undefined && onInit === undefined;
if (!(0, exports.isHtmlInCanvasSupported)()) {
cancelRender(new Error(exports.HTML_IN_CANVAS_UNSUPPORTED_MESSAGE));
}
const canvas2dRef = (0, react_1.useRef)(null);
const paintTargetRef = (0, react_1.useRef)(null);
const divRef = (0, react_1.useRef)(null);
const canvasSizeKey = `${width}x${height}@${resolvedPixelDensity}-${usesDirectLayoutCanvas ? 'direct' : 'offscreen'}`;
const setLayoutCanvasRef = (0, react_1.useCallback)((node) => {
canvas2dRef.current = node;
if (typeof ref === 'function') {
ref(node);
}
else if (ref) {
ref.current = node;
}
}, [ref]);
const chainState = (0, use_effect_chain_state_js_1.useEffectChainState)();
const memoizedEffects = (0, use_memoized_effects_js_1.useMemoizedEffects)({
effects,
overrideId: (_a = controls === null || controls === void 0 ? void 0 : controls.overrideId) !== null && _a !== void 0 ? _a : null,
});
// Refs so the paint handler always reads fresh values.
const effectsRef = (0, react_1.useRef)(memoizedEffects);
effectsRef.current = memoizedEffects;
const onPaintRef = (0, react_1.useRef)(onPaint);
onPaintRef.current = onPaint;
const onInitRef = (0, react_1.useRef)(onInit);
onInitRef.current = onInit;
const initializedRef = (0, react_1.useRef)(false);
const onInitCleanupRef = (0, react_1.useRef)(null);
const unmountedRef = (0, react_1.useRef)(false);
const onPaintCb = (0, react_1.useCallback)(async () => {
const element = divRef.current;
if (!element) {
throw new Error('Canvas or scene element not found');
}
const paintTarget = paintTargetRef.current;
if (!paintTarget) {
throw new Error('HtmlInCanvas: paint target is not ready because the canvas is remounting');
}
resizePaintTarget({
target: paintTarget,
width: canvasWidth,
height: canvasHeight,
});
try {
const placeholderCanvas = canvas2dRef.current;
if (!placeholderCanvas) {
throw new Error('Canvas not found');
}
const handle = delayRender('onPaint');
if (!initializedRef.current) {
const currentOnInit = onInitRef.current;
if (!currentOnInit) {
initializedRef.current = true;
}
else {
// `onInit` may be async (e.g. WebGPU
// `requestAdapter`/`requestDevice`). Do not reuse this capture for
// `onPaint`: awaiting initialization can invalidate its paint context.
let initImage;
try {
initImage = placeholderCanvas.captureElementImage(element);
}
catch (error) {
if (isMissingPaintRecordError(error) &&
canRetryMissingPaintRecord) {
// The web renderer explicitly drives additional paint cycles, so a
// transient missing record can be retried without failing the render.
continueRender(handle);
return;
}
if (isMissingPaintRecordError(error)) {
throw new Error(missingPaintRecordMessage);
}
throw error;
}
initializedRef.current = true;
try {
if (paintTarget instanceof HTMLCanvasElement) {
throw new Error('HtmlInCanvas: onInit requires an OffscreenCanvas paint target');
}
const cleanup = await currentOnInit({
canvas: paintTarget,
element,
elementImage: initImage,
pixelDensity: resolvedPixelDensity,
});
if (typeof cleanup !== 'function') {
throw new Error('HtmlInCanvas: when `onInit` is provided, it must return a cleanup function, or a Promise that resolves to one.');
}
if (unmountedRef.current) {
cleanup();
}
else {
onInitCleanupRef.current = cleanup;
}
}
finally {
initImage.close();
}
}
}
let elImage;
try {
elImage = placeholderCanvas.captureElementImage(element);
}
catch (error) {
// `captureElementImage` throws `InvalidStateError` when the
// element is outside the viewport (no cached paint record).
// Skip this paint cycle — the canvas retains its last state.
if (isMissingPaintRecordError(error) && canRetryMissingPaintRecord) {
continueRender(handle);
return;
}
if (isMissingPaintRecordError(error)) {
throw new Error(missingPaintRecordMessage);
}
throw error;
}
try {
const currentOnPaint = onPaintRef.current;
if (currentOnPaint) {
if (paintTarget instanceof HTMLCanvasElement) {
throw new Error('HtmlInCanvas: onPaint requires an OffscreenCanvas paint target');
}
const paintResult = currentOnPaint({
canvas: paintTarget,
element,
elementImage: elImage,
pixelDensity: resolvedPixelDensity,
});
if (paintResult) {
await paintResult;
}
}
else {
defaultOnPaint({
canvas: paintTarget,
element,
elementImage: elImage,
pixelDensity: resolvedPixelDensity,
});
}
await (0, run_effect_chain_js_1.runEffectChain)({
state: chainState.get(canvasWidth, canvasHeight),
source: paintTarget,
effects: effectsRef.current,
output: paintTarget,
width: canvasWidth,
height: canvasHeight,
});
}
finally {
elImage.close();
}
continueRender(handle);
}
catch (error) {
cancelRender(error);
}
}, [
canvasHeight,
canvasWidth,
chainState,
continueRender,
cancelRender,
delayRender,
resolvedPixelDensity,
canRetryMissingPaintRecord,
]);
// Default paint handlers draw synchronously on the layout canvas itself.
// Custom handlers retain the transferred OffscreenCanvas API.
(0, react_1.useLayoutEffect)(() => {
const placeholder = canvas2dRef.current;
if (!placeholder) {
throw new Error('Canvas not found');
}
placeholder.layoutSubtree = true;
const paintTarget = usesDirectLayoutCanvas
? placeholder
: getTransferredOffscreenCanvas(placeholder);
paintTargetRef.current = paintTarget;
resizePaintTarget({
target: paintTarget,
width: canvasWidth,
height: canvasHeight,
});
initializedRef.current = false;
unmountedRef.current = false;
placeholder.addEventListener('paint', onPaintCb);
return () => {
var _a;
placeholder.removeEventListener('paint', onPaintCb);
paintTargetRef.current = null;
initializedRef.current = false;
unmountedRef.current = true;
(_a = onInitCleanupRef.current) === null || _a === void 0 ? void 0 : _a.call(onInitCleanupRef);
onInitCleanupRef.current = null;
};
}, [
onPaintCb,
cancelRender,
canvasWidth,
canvasHeight,
usesDirectLayoutCanvas,
]);
const onPaintChangedRef = (0, react_1.useRef)(false);
(0, react_1.useLayoutEffect)(() => {
var _a;
if (!onPaintChangedRef.current) {
onPaintChangedRef.current = true;
return;
}
const canvas = canvas2dRef.current;
if (!canvas) {
return;
}
(_a = canvas.requestPaint) === null || _a === void 0 ? void 0 : _a.call(canvas);
}, [onPaint, memoizedEffects]);
(0, react_1.useLayoutEffect)(() => {
const canvas = canvas2dRef.current;
if (!canvas) {
return;
}
const handle = delayRender('waiting for first paint after canvas resize');
canvas.addEventListener('paint', () => {
continueRender(handle);
}, { once: true });
return () => {
continueRender(handle);
};
}, [width, height, continueRender, delayRender, canvasSizeKey]);
const innerStyle = (0, react_1.useMemo)(() => {
return {
width,
height,
};
}, [width, height]);
const canvasStyle = (0, react_1.useMemo)(() => {
return {
width,
height,
...(style !== null && style !== void 0 ? style : {}),
};
}, [height, style, width]);
return (jsx_runtime_1.jsx(HtmlInCanvasAncestorContext.Provider, { value: true, children: jsx_runtime_1.jsx("canvas", { ref: setLayoutCanvasRef, width: canvasWidth, height: canvasHeight, style: canvasStyle, children: jsx_runtime_1.jsx("div", { ref: divRef, style: innerStyle, children: children }) }, canvasSizeKey) }));
});
HtmlInCanvasContent.displayName = 'HtmlInCanvasContent';
const HtmlInCanvasInner = (0, react_1.forwardRef)(({ width, height, effects = [], children, onPaint, onInit, pixelDensity, controls, style, cropLeft, cropRight, cropTop, cropBottom, durationInFrames, name, ...sequenceProps }, ref) => {
const memoizedEffectDefinitions = (0, use_memoized_effects_js_1.useMemoizedEffectDefinitions)(effects);
const actualRef = (0, react_1.useRef)(null);
const setCanvasRef = (0, react_1.useCallback)((node) => {
actualRef.current = node;
if (typeof ref === 'function') {
ref(node);
}
else if (ref) {
ref.current = node;
}
}, [ref]);
const croppedStyle = (0, use_crop_style_js_1.useCropStyle)({
cropLeft,
cropRight,
cropTop,
cropBottom,
style: style !== null && style !== void 0 ? style : null,
componentName: '<HtmlInCanvas />',
});
return (jsx_runtime_1.jsx(Sequence_js_1.Sequence, { durationInFrames: durationInFrames, name: name !== null && name !== void 0 ? name : '<HtmlInCanvas>', _remotionInternalDocumentationLink: "https://www.remotion.dev/docs/remotion/html-in-canvas", controls: controls, _remotionInternalEffects: memoizedEffectDefinitions, outlineRef: actualRef, layout: "none", ...sequenceProps, children: jsx_runtime_1.jsx(HtmlInCanvasContent, { ref: setCanvasRef, width: width, height: height, effects: effects, onPaint: onPaint, onInit: onInit, pixelDensity: pixelDensity, controls: controls, style: croppedStyle !== null && croppedStyle !== void 0 ? croppedStyle : undefined, children: children }) }));
});
HtmlInCanvasInner.displayName = 'HtmlInCanvas';
exports.htmlInCanvasSchema = {
...interactivity_schema_js_1.baseSchema,
pixelDensity: {
type: 'number',
min: 1,
max: 3,
step: 0.1,
default: 1,
description: 'Pixel density',
hiddenFromList: false,
},
...interactivity_schema_js_1.transformSchema,
...interactivity_schema_js_1.backgroundSchema,
...interactivity_schema_js_1.borderSchema,
...interactivity_schema_js_1.borderRadiusSchema,
...interactivity_schema_js_1.cropSchema,
};
const HtmlInCanvasWrapped = (0, with_interactivity_schema_js_1.withInteractivitySchema)({
Component: HtmlInCanvasInner,
componentName: '<HtmlInCanvas>',
componentIdentity: 'dev.remotion.remotion.HtmlInCanvas',
schema: exports.htmlInCanvasSchema,
supportsEffects: true,
});
exports.HtmlInCanvas = Object.assign(HtmlInCanvasWrapped, {
isSupported: exports.isHtmlInCanvasSupported,
});
exports.HtmlInCanvas.displayName = 'HtmlInCanvas';
(0, enable_sequence_stack_traces_js_1.addSequenceStackTraces)(exports.HtmlInCanvas);