UNPKG

remotion

Version:

Make videos programmatically

416 lines (415 loc) 19.3 kB
"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);