diff --git a/packages/core/src/HtmlInCanvas.tsx b/packages/core/src/HtmlInCanvas.tsx index f2417fe1064..53f000a7118 100644 --- a/packages/core/src/HtmlInCanvas.tsx +++ b/packages/core/src/HtmlInCanvas.tsx @@ -8,7 +8,7 @@ import React, { useRef, } from 'react'; import type {SequenceControls} from './CompositionManager.js'; -import type {EffectsProp} from './effects/effect-types.js'; +import type {EffectsProp, EffectsOutputSize} from './effects/effect-types.js'; import {runEffectChain} from './effects/run-effect-chain.js'; import {useEffectChainState} from './effects/use-effect-chain-state.js'; import { @@ -450,6 +450,7 @@ export type HtmlInCanvasProps = Omit & readonly width: number; readonly height: number; readonly effects?: EffectsProp; + readonly effectsOutputSize?: EffectsOutputSize; readonly children: React.ReactNode; readonly onPaint?: HtmlInCanvasOnPaint; readonly onInit?: HtmlInCanvasOnInit; @@ -473,6 +474,7 @@ type HtmlInCanvasContentProps = { readonly width: number; readonly height: number; readonly effects: EffectsProp; + readonly effectsOutputSize: EffectsOutputSize | null; readonly children: React.ReactNode; readonly canvasSiblings: React.ReactNode | null; readonly onPaint: HtmlInCanvasOnPaint | undefined; @@ -491,6 +493,7 @@ const HtmlInCanvasContent = forwardRef< width, height, effects, + effectsOutputSize, children, canvasSiblings, onPaint, @@ -586,7 +589,7 @@ const HtmlInCanvasContent = forwardRef< [ref, paintTargetMode], ); - const chainState = useEffectChainState(); + const chainState = useEffectChainState(effectsOutputSize); const memoizedEffects = useMemoizedEffects({ effects, @@ -1026,6 +1029,7 @@ const HtmlInCanvasInner = forwardRef< width, height, effects = [], + effectsOutputSize, children, onPaint, onInit, @@ -1121,6 +1125,7 @@ const HtmlInCanvasInner = forwardRef< width={width} height={height} effects={effects} + effectsOutputSize={effectsOutputSize ?? null} onPaint={onPaint} onInit={onInit} pixelDensity={pixelDensity} diff --git a/packages/core/src/Img.tsx b/packages/core/src/Img.tsx index 716f6f653c0..07efe8c285e 100644 --- a/packages/core/src/Img.tsx +++ b/packages/core/src/Img.tsx @@ -14,7 +14,7 @@ import type { CanvasImageProps, } from './canvas-image/props.js'; import type {SequenceControls} from './CompositionManager.js'; -import type {EffectsProp} from './effects/effect-types.js'; +import type {EffectsProp, EffectsOutputSize} from './effects/effect-types.js'; import {addSequenceStackTraces} from './enable-sequence-stack-traces.js'; import {Freeze} from './freeze.js'; import {getCrossOriginValue} from './get-cross-origin-value.js'; @@ -68,6 +68,7 @@ export type ImgProps = NativeImgProps & { readonly onImageError?: (error: Error) => void; readonly src: string; readonly effects?: EffectsProp; + readonly effectsOutputSize?: EffectsOutputSize; readonly showInTimeline?: boolean; readonly name?: string; } & Omit & @@ -90,6 +91,7 @@ type ImgContentProps = Omit< | 'durationInFrames' | 'freeze' | 'effects' + | 'effectsOutputSize' | 'premountFor' | 'postmountFor' | 'styleWhilePremounted' @@ -353,7 +355,7 @@ const ImgContent: React.FC = ({ ); }; -type NativeImgInnerProps = Omit & { +type NativeImgInnerProps = Omit & { readonly controls: SequenceControls | undefined; }; @@ -557,6 +559,7 @@ const ImgInner: React.FC< } > = ({ effects = [], + effectsOutputSize, ref, hidden, name, @@ -648,6 +651,7 @@ const ImgInner: React.FC< height={canvasHeight} fit={canvasFit} effects={effects} + effectsOutputSize={effectsOutputSize} className={className} style={style} cropLeft={cropLeft} diff --git a/packages/core/src/animated-image/AnimatedImage.tsx b/packages/core/src/animated-image/AnimatedImage.tsx index 87172bde4e2..02bb409d7df 100644 --- a/packages/core/src/animated-image/AnimatedImage.tsx +++ b/packages/core/src/animated-image/AnimatedImage.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react'; import type {SequenceControls} from '../CompositionManager.js'; -import type {EffectsProp} from '../effects/effect-types.js'; +import type {EffectsProp, EffectsOutputSize} from '../effects/effect-types.js'; import { useMemoizedEffectDefinitions, useMemoizedEffects, @@ -87,6 +87,7 @@ const getCanvasPropsFromSequenceProps = ( type AnimatedImageContentProps = RemotionAnimatedImageProps & { readonly effects: EffectsProp; + readonly effectsOutputSize: EffectsOutputSize | null; readonly controls: SequenceControls | undefined; }; @@ -105,6 +106,7 @@ const AnimatedImageContent = forwardRef< fit = 'fill', requestInit, effects, + effectsOutputSize, controls, ...props }, @@ -280,6 +282,8 @@ const AnimatedImageContent = forwardRef< width, height, cancelRender, + effectsOutputSize?.width, + effectsOutputSize?.height, ]); return ( @@ -289,6 +293,7 @@ const AnimatedImageContent = forwardRef< height={height} fit={fit} effects={memoizedEffects} + effectsOutputSize={effectsOutputSize ?? null} {...props} /> ); @@ -320,6 +325,7 @@ const AnimatedImageInner = ({ cropBottom, requestInit, effects = [], + effectsOutputSize, controls, ref, ...sequenceProps @@ -409,6 +415,7 @@ const AnimatedImageInner = ({ {...animatedImageProps} ref={actualRef} effects={effects} + effectsOutputSize={effectsOutputSize ?? null} controls={controls} /> diff --git a/packages/core/src/animated-image/canvas.tsx b/packages/core/src/animated-image/canvas.tsx index fd74e6d856c..f2a3c43ac87 100644 --- a/packages/core/src/animated-image/canvas.tsx +++ b/packages/core/src/animated-image/canvas.tsx @@ -1,6 +1,9 @@ import React, {useCallback, useImperativeHandle, useMemo, useRef} from 'react'; import {calculateImageFit} from '../calculate-image-fit.js'; -import type {EffectDefinitionAndStack} from '../effects/effect-types.js'; +import type { + EffectDefinitionAndStack, + EffectsOutputSize, +} from '../effects/effect-types.js'; import {runEffectChain} from '../effects/run-effect-chain.js'; import {useEffectChainState} from '../effects/use-effect-chain-state.js'; import type {AnimatedImageCanvasProps, AnimatedImageFillMode} from './props'; @@ -15,6 +18,7 @@ type Props = { readonly style?: React.CSSProperties; readonly effects: EffectDefinitionAndStack[]; + readonly effectsOutputSize: EffectsOutputSize | null; } & AnimatedImageCanvasProps; export type AnimatedImageCanvasRef = { @@ -26,9 +30,12 @@ export type AnimatedImageCanvasRef = { const CanvasRefForwardingFunction: React.ForwardRefRenderFunction< AnimatedImageCanvasRef, Props -> = ({width, height, fit, className, style, effects, ...props}, ref) => { +> = ( + {width, height, fit, className, style, effects, effectsOutputSize, ...props}, + ref, +) => { const canvasRef = useRef(null); - const chainState = useEffectChainState(); + const chainState = useEffectChainState(effectsOutputSize); const sourceCanvas = useMemo(() => { if (typeof document === 'undefined') { diff --git a/packages/core/src/animated-image/props.ts b/packages/core/src/animated-image/props.ts index b118d579c0d..283bbe1354d 100644 --- a/packages/core/src/animated-image/props.ts +++ b/packages/core/src/animated-image/props.ts @@ -1,5 +1,5 @@ import type {ImageFit} from '../calculate-image-fit.js'; -import type {EffectsProp} from '../effects/effect-types.js'; +import type {EffectsProp, EffectsOutputSize} from '../effects/effect-types.js'; import type { InteractiveBaseProps, InteractiveCropProps, @@ -33,6 +33,7 @@ export type AnimatedImageProps = InteractiveBaseProps & InteractivePremountProps & RemotionAnimatedImageProps & { readonly effects?: EffectsProp; + readonly effectsOutputSize?: EffectsOutputSize; }; export type AnimatedImageFillMode = ImageFit; diff --git a/packages/core/src/canvas-image/CanvasImage.tsx b/packages/core/src/canvas-image/CanvasImage.tsx index ff3fe802d92..85ee03290c7 100644 --- a/packages/core/src/canvas-image/CanvasImage.tsx +++ b/packages/core/src/canvas-image/CanvasImage.tsx @@ -10,7 +10,7 @@ import { } from 'react'; import {calculateImageFit} from '../calculate-image-fit.js'; import type {SequenceControls} from '../CompositionManager.js'; -import type {EffectsProp} from '../effects/effect-types.js'; +import type {EffectsProp, EffectsOutputSize} from '../effects/effect-types.js'; import {runEffectChain} from '../effects/run-effect-chain.js'; import {useEffectChainState} from '../effects/use-effect-chain-state.js'; import { @@ -203,6 +203,7 @@ type CanvasImageContentProps = Pick< | 'width' > & { readonly effects: EffectsProp; + readonly effectsOutputSize: EffectsOutputSize | null; readonly controls: SequenceControls | undefined; } & CanvasImageCanvasProps; @@ -218,6 +219,7 @@ const CanvasImageContent = forwardRef< height, fit = 'fill', effects, + effectsOutputSize, controls, onError, className, @@ -238,7 +240,7 @@ const CanvasImageContent = forwardRef< ); const [loadedImage, setLoadedImage] = useState(null); const actualSrc = usePreload(src); - const chainState = useEffectChainState(); + const chainState = useEffectChainState(effectsOutputSize); const memoizedEffects = useMemoizedEffects({ effects, overrideId: controls?.overrideId ?? null, @@ -529,6 +531,7 @@ const CanvasImageInner = forwardRef< height, fit, effects = [], + effectsOutputSize, className, style, id, @@ -645,6 +648,7 @@ const CanvasImageInner = forwardRef< height={height} fit={fit} effects={effects} + effectsOutputSize={effectsOutputSize ?? null} controls={controls} className={className} style={croppedStyle ?? undefined} diff --git a/packages/core/src/canvas-image/props.ts b/packages/core/src/canvas-image/props.ts index 211f82ff7a4..c090cf18863 100644 --- a/packages/core/src/canvas-image/props.ts +++ b/packages/core/src/canvas-image/props.ts @@ -1,6 +1,6 @@ import type React from 'react'; import type {ImageFit} from '../calculate-image-fit.js'; -import type {EffectsProp} from '../effects/effect-types.js'; +import type {EffectsProp, EffectsOutputSize} from '../effects/effect-types.js'; import type { InteractiveBaseProps, InteractiveCropProps, @@ -24,6 +24,7 @@ export type CanvasImageProps = CanvasImageSequenceProps & readonly height?: number; readonly fit?: ImageFit; readonly effects?: EffectsProp; + readonly effectsOutputSize?: EffectsOutputSize; readonly className?: string; readonly style?: React.CSSProperties; readonly id?: string; diff --git a/packages/core/src/effects/Solid.tsx b/packages/core/src/effects/Solid.tsx index 38d7ce395c4..6bc30778387 100644 --- a/packages/core/src/effects/Solid.tsx +++ b/packages/core/src/effects/Solid.tsx @@ -33,7 +33,7 @@ import {useDelayRender} from '../use-delay-render.js'; import {usePremounting} from '../use-premounting.js'; import type {VideoConfigValues} from '../video-config.js'; import {withInteractivitySchema} from '../with-interactivity-schema.js'; -import type {EffectsProp} from './effect-types.js'; +import type {EffectsProp, EffectsOutputSize} from './effect-types.js'; import {runEffectChain} from './run-effect-chain.js'; import {useEffectChainState} from './use-effect-chain-state.js'; import { @@ -49,6 +49,7 @@ type MandatoryProps = { type OptionalProps = { readonly color: string | undefined; readonly effects: EffectsProp; + readonly effectsOutputSize: EffectsOutputSize | null; readonly className: string | undefined; readonly style: React.CSSProperties | undefined; readonly pixelDensity: number | undefined; @@ -131,6 +132,7 @@ const SolidInner: React.FC< width, height, effects = [], + effectsOutputSize = null, className, style, pixelDensity, @@ -165,7 +167,7 @@ const SolidInner: React.FC< return canvas; }, []); - const chainState = useEffectChainState(); + const chainState = useEffectChainState(effectsOutputSize); const canvasRef = useCallback( (canvas: HTMLCanvasElement | null) => { @@ -271,6 +273,7 @@ const SolidOuter = forwardRef< ( { effects = [], + effectsOutputSize = null, controls, color, height, @@ -375,6 +378,7 @@ const SolidOuter = forwardRef< className={className} style={croppedStyle ?? undefined} effects={effects} + effectsOutputSize={effectsOutputSize} pixelDensity={pixelDensity} /> diff --git a/packages/core/src/effects/effect-types.ts b/packages/core/src/effects/effect-types.ts index 8d777f981a4..8107990ab67 100644 --- a/packages/core/src/effects/effect-types.ts +++ b/packages/core/src/effects/effect-types.ts @@ -84,3 +84,8 @@ export type EffectsProp = ReadonlyArray>; export type EffectFactory

= {} extends P ? (params?: P & {readonly disabled?: boolean}) => EffectDescriptor : (params: P & {readonly disabled?: boolean}) => EffectDescriptor; + +export type EffectsOutputSize = { + readonly width: number; + readonly height: number; +}; diff --git a/packages/core/src/effects/index.ts b/packages/core/src/effects/index.ts index f8a2cf5fdc4..e1f7440cf1a 100644 --- a/packages/core/src/effects/index.ts +++ b/packages/core/src/effects/index.ts @@ -7,6 +7,7 @@ export type { EffectDefinitionAndStack, EffectDescriptor, EffectsProp, + EffectsOutputSize, EffectDefinition, EffectFactory, } from './effect-types.js'; diff --git a/packages/core/src/effects/run-effect-chain.ts b/packages/core/src/effects/run-effect-chain.ts index 9970352b972..4253ac90e69 100644 --- a/packages/core/src/effects/run-effect-chain.ts +++ b/packages/core/src/effects/run-effect-chain.ts @@ -7,6 +7,8 @@ import type { import {getGpuDevice} from './gpu-device.js'; export type EffectChainState = { + readonly width: number; + readonly height: number; pool: CanvasPool; setupCache: WeakMap< EffectDefinition, @@ -25,6 +27,8 @@ export const createEffectChainState = ( height: number, ): EffectChainState => { const state: EffectChainState = { + width, + height, pool: new CanvasPool(width, height, (canvas) => { // The GL objects these setups hold died with the context and stay // invalid on a restored one, so the next run has to recreate them. @@ -104,14 +108,10 @@ export type RunEffectChainOptions = { // Runs the effect pipeline imperatively. Returns `true` if the pipeline // completed and wrote to `output`, `false` if it was superseded by a newer // run (caller should not act on a stale result). -export const runEffectChain = async ({ - state, - source, - effects, - output, - width, - height, -}: RunEffectChainOptions): Promise => { +const runEffectChainWithoutResize = async ( + {state, source, effects, output, width, height}: RunEffectChainOptions, + initialFlipSourceY: boolean, +): Promise => { const runId = ++state.currentRunId; const isCancelled = () => state.currentRunId !== runId; @@ -160,7 +160,7 @@ export const runEffectChain = async ({ // Canvas sources are DOM-oriented. Flip them when uploading into WebGL so // texture coordinates match clip-space output. `ImageBitmap` bridges below // opt out because they are already oriented for upload. - let flipWebGLSourceY = true; + let flipWebGLSourceY = initialFlipSourceY; for (let runIndex = 0; runIndex < runs.length; runIndex++) { const run = runs[runIndex]; @@ -182,8 +182,8 @@ export const runEffectChain = async ({ target: dst, state: setupState, params: eff.params, - width, - height, + width: state.width, + height: state.height, gpuDevice, flipSourceY: run.backend === 'webgl2' ? flipWebGLSourceY : false, }); @@ -242,3 +242,40 @@ export const runEffectChain = async ({ outCtx.drawImage(lastTarget, 0, 0, width, height); return true; }; + +// Keep ownership of the optional resized source until the existing chain settles. +export const runEffectChain = async ( + options: RunEffectChainOptions, +): Promise => { + const {state, source, width, height, effects} = options; + if ( + (state.width === width && state.height === height) || + !effects.some((effect) => !(effect.params as {disabled?: boolean}).disabled) + ) { + return runEffectChainWithoutResize(options, true); + } + + const runId = ++state.currentRunId; + const firstEnabledEffect = effects.find( + (effect) => !(effect.params as {disabled?: boolean}).disabled, + )!; + const resized = await createImageBitmap(source as ImageBitmapSource, { + resizeWidth: state.width, + resizeHeight: state.height, + // WebGL ignores UNPACK_FLIP_Y_WEBGL for ImageBitmap uploads. + imageOrientation: + firstEnabledEffect.definition.backend === 'webgl2' ? 'flipY' : 'none', + }); + try { + if (state.currentRunId !== runId) { + return false; + } + + return await runEffectChainWithoutResize( + {...options, source: resized}, + false, + ); + } finally { + resized.close(); + } +}; diff --git a/packages/core/src/effects/use-effect-chain-state.ts b/packages/core/src/effects/use-effect-chain-state.ts index 22d7309bbb8..298c6659342 100644 --- a/packages/core/src/effects/use-effect-chain-state.ts +++ b/packages/core/src/effects/use-effect-chain-state.ts @@ -1,13 +1,32 @@ import {useEffect, useMemo, useRef} from 'react'; +import {validateDimension} from '../validation/validate-dimensions.js'; +import type {EffectsOutputSize} from './effect-types.js'; import type {EffectChainState} from './run-effect-chain.js'; import { cleanupEffectChainState, createEffectChainState, } from './run-effect-chain.js'; -export const useEffectChainState = (): { +export const useEffectChainState = ( + effectsOutputSize: EffectsOutputSize | null = null, +): { get: (width: number, height: number) => EffectChainState | null; } => { + const effectWidth = effectsOutputSize?.width; + const effectHeight = effectsOutputSize?.height; + if (effectsOutputSize) { + validateDimension( + effectWidth, + 'effectsOutputSize.width', + 'on an effect-capable component', + ); + validateDimension( + effectHeight, + 'effectsOutputSize.height', + 'on an effect-capable component', + ); + } + const chainStateRef = useRef(null); const sizeRef = useRef<{width: number; height: number} | null>(null); const unmountedRef = useRef(false); @@ -30,6 +49,8 @@ export const useEffectChainState = (): { return useMemo( () => ({ get: (width, height) => { + width = effectWidth ?? width; + height = effectHeight ?? height; // Async work such as a late decoded video frame can still get here // after unmount. Nothing would release a pool created at this point. if (unmountedRef.current) { @@ -52,6 +73,6 @@ export const useEffectChainState = (): { return chainStateRef.current; }, }), - [], + [effectWidth, effectHeight], ); }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 19aab89a8d1..ba1618585e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,6 +157,7 @@ export { type EffectDescriptor, type EffectFactory, type EffectsProp, + type EffectsOutputSize, } from './effects/index.js'; /** * @description Renders a solid-color rectangle on a ``. diff --git a/packages/core/src/test/animated-image.test.tsx b/packages/core/src/test/animated-image.test.tsx index 0688b90f861..651bd588dfb 100644 --- a/packages/core/src/test/animated-image.test.tsx +++ b/packages/core/src/test/animated-image.test.tsx @@ -52,7 +52,13 @@ test('AnimatedImage retains a frame drawn while its canvas is hidden', async () const ref = React.createRef(); const rendered = render( - , + , ); const canvas = rendered.container.querySelector('canvas'); if (!canvas) { @@ -73,7 +79,13 @@ test('AnimatedImage retains a frame drawn while its canvas is hidden', async () expect(pixels.get(canvas)).toBe('initial-frame'); rendered.rerender( - , + , ); expect(rendered.container.querySelector('canvas')).toBe(canvas); diff --git a/packages/core/src/test/effect-internals.test.ts b/packages/core/src/test/effect-internals.test.ts index 0ed07c8fa6b..f57c66d0de7 100644 --- a/packages/core/src/test/effect-internals.test.ts +++ b/packages/core/src/test/effect-internals.test.ts @@ -48,6 +48,37 @@ const memoizeEffects = ( })); }; +// PR #11436: a size change or unmount can cancel an asynchronous source resize. +test('closes a resized effect source after its chain is cancelled', async () => { + const originalBitmap = globalThis.createImageBitmap; + let finishResize!: (bitmap: ImageBitmap) => void; + const close = mock(() => undefined); + globalThis.createImageBitmap = mock( + () => + new Promise((resolve) => { + finishResize = resolve; + }), + ) as typeof createImageBitmap; + const state = createEffectChainState(2, 2); + try { + const rendering = runEffectChain({ + state, + source: document.createElement('canvas'), + output: document.createElement('canvas'), + width: 4, + height: 4, + effects: memoizeEffects([makeDesc('cancelled', '2d')]), + }); + cleanupEffectChainState(state); + finishResize({close} as unknown as ImageBitmap); + expect(await rendering).toBe(false); + expect(close).toHaveBeenCalledTimes(1); + } finally { + globalThis.createImageBitmap = originalBitmap; + cleanupEffectChainState(state); + } +}); + test('groupByBackend collapses adjacent same-backend effects', () => { const effects = [ makeDesc('a', '2d'), @@ -97,25 +128,6 @@ test('groupByBackend returns one run per backend transition', () => { expect(runs.map((r) => r.backend)).toEqual(['2d', 'webgl2', '2d', 'webgl2']); }); -test('runEffectChain filters disabled effects before grouping', () => { - // Mirror of the filter in `runEffectChain` — kept here as a regression - // guard so the behavior is asserted independently of the canvas-bound - // chain runner. - const all: EffectDescriptor[] = [ - {...makeDesc('a', '2d'), params: {disabled: false}}, - {...makeDesc('b', '2d'), params: {disabled: true}}, - {...makeDesc('c', 'webgl2'), params: {}}, - {...makeDesc('d', 'webgl2'), params: {disabled: true}}, - ]; - const enabled = all.filter( - (e) => !(e.params as {disabled?: boolean}).disabled, - ); - const runs = groupByBackend(memoizeEffects(enabled)); - expect(runs).toHaveLength(2); - expect(runs[0].effects.map((e) => e.definition.type)).toEqual(['a']); - expect(runs[1].effects.map((e) => e.definition.type)).toEqual(['c']); -}); - test.each(['2d', 'webgl2'] as const)( 'runEffectChain allocates only needed %s targets and releases them', async (backend) => { diff --git a/packages/docs/docs/effects/index.mdx b/packages/docs/docs/effects/index.mdx index cff6fc67679..d15f222e2af 100644 --- a/packages/docs/docs/effects/index.mdx +++ b/packages/docs/docs/effects/index.mdx @@ -35,6 +35,29 @@ Pass effects through the `effects` prop of these components: - [``](/docs/shapes/heart) - [``](/docs/shapes/arrow) +## Effect resolution + +Use `effectsOutputSize` on any supported component to choose the pixel dimensions used by its effect chain: + +```tsx twoslash +import {Video} from '@remotion/media'; +import {pixelate} from '@remotion/effects/pixelate'; +// ---cut--- +