Organic dissolve
A seeded grain pattern eats away the outgoing scene and grows the incoming one in, like film grain dissolving between two shots.
OrganicDissolve, default
Installation
npx shadcn add https://www.reelcn.dev/r/organic-dissolve.jsonFiles land in src/reelcn/ with the items it builds on. Dependencies: @remotion/transitions, remotion.
Usage
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneA />
</TransitionSeries.Sequence>
<TransitionSeries.Transition presentation={organicDissolve({ seed: "scene-1" })} timing={linearTiming({ durationInFrames: 26 })} />
<TransitionSeries.Sequence durationInFrames={60}>
<SceneB />
</TransitionSeries.Sequence>
</TransitionSeries>Props
PropTypeDescription
seedstringDeterministic per instance: the same seed always dissolves in the same pattern.
grainnumberGrain size in design units. Larger = coarser grain.
Built from
Use
- A softer, less mechanical alternative to a hard cut or a wipe
- Documentary or lifestyle edits that want texture, not geometry
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/organic-dissolve.tsx
/**
* @title Organic Dissolve
* @description A seeded grain pattern eats away the outgoing scene and grows the incoming one in, like film grain dissolving between two shots.
* @category transitions
* @duration 26
* @use A softer, less mechanical alternative to a hard cut or a wipe
* @use Documentary or lifestyle edits that want texture, not geometry
* @tags dissolve, grain, organic, turbulence, seed
* @example
* <TransitionSeries>
* <TransitionSeries.Sequence durationInFrames={60}>
* <SceneA />
* </TransitionSeries.Sequence>
* <TransitionSeries.Transition presentation={organicDissolve({ seed: "scene-1" })} timing={linearTiming({ durationInFrames: 26 })} />
* <TransitionSeries.Sequence durationInFrames={60}>
* <SceneB />
* </TransitionSeries.Sequence>
* </TransitionSeries>
*/
import type { TransitionPresentation, TransitionPresentationComponentProps } from "@remotion/transitions";
import { useId } from "react";
import { AbsoluteFill } from "remotion";
import { useViewport } from "./core";
export type OrganicDissolveProps = {
/** Deterministic per instance: the same seed always dissolves in the same pattern. */
seed?: string;
/** Grain size in design units. Larger = coarser grain. */
grain?: number;
};
/** A small, fixed string hash, deterministic, not `Math.random`, turned into `feTurbulence`'s numeric `seed`. */
function seedToNumber(seed: string): number {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) % 1000;
return Math.abs(hash);
}
function OrganicDissolvePresentation({
children,
presentationDirection,
presentationProgress,
passedProps,
}: TransitionPresentationComponentProps<OrganicDissolveProps>) {
const { u } = useViewport();
// Hooks before the early returns below: `presentationProgress` crossing 0 or 1 must not change the hook count.
const instanceId = useId();
const seed = passedProps.seed ?? "organic-dissolve";
const grain = passedProps.grain ?? 40;
const exiting = presentationDirection === "exiting";
// The exiting scene stays fully opaque underneath for the whole transition; only the entering scene
// dissolves in on top via the grain mask below, across the full 0→1 progress. coverPhase's sequential
// cover/reveal split (cover 1→0 on [0, 0.5], reveal 0→1 on [0.5, 1]) made both layers hit 0 at the
// p=0.5 swap point, a flat black frame, which is right for a hard cover/reveal transition (one layer
// is meant to fully occlude the other) but wrong for a cross-dissolve, where both scenes must be
// visibly mixed at the midpoint. Same fix as rack-focus.tsx's opaque exiting layer.
if (exiting) return <AbsoluteFill>{children}</AbsoluteFill>;
// 1 = shown exactly as its plain scene, 0 = fully dissolved away.
const revealAmount = presentationProgress;
if (revealAmount >= 1) return <AbsoluteFill>{children}</AbsoluteFill>;
if (revealAmount <= 0) return <AbsoluteFill style={{ opacity: 0 }}>{children}</AbsoluteFill>;
const baseFrequency = 1 / Math.max(u(grain), 1);
const numericSeed = seedToNumber(seed);
// Per-instance, not per-direction: see whip-pan's identical fix for why a static id collides.
const filterId = `organic-dissolve-${instanceId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const displaceStrength = u(24) * 4 * revealAmount * (1 - revealAmount);
// The threshold sweeps through the turbulence's own alpha channel (which realistically only spans
// roughly [0, 1], not [-1, 1]), so grains disappear/appear in the same organic pattern the noise
// already drew instead of a uniform cross-fade. Centering that sweep on `revealAmount - 1` (rather
// than `- 0.5`) means the threshold only clears the alpha channel's real range at revealAmount ≈ 1,
// not ≈ 0.5, otherwise the whole dissolve finishes by the transition's own halfway point and its
// true midpoint renders as a plain, fully-revealed frame instead of a visible mix of both scenes.
const cutoff = (revealAmount - 1) * 24;
return (
<AbsoluteFill style={{ overflow: "hidden" }}>
<svg aria-hidden="true" width={0} height={0} style={{ position: "absolute" }}>
<defs>
<filter id={filterId} x="-20%" y="-20%" width="140%" height="140%" colorInterpolationFilters="sRGB">
<feTurbulence
type="fractalNoise"
baseFrequency={baseFrequency}
numOctaves={2}
seed={numericSeed}
result="noise"
/>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale={displaceStrength}
xChannelSelector="R"
yChannelSelector="G"
result="displaced"
/>
<feComponentTransfer in="noise" result="mask">
<feFuncA type="linear" slope={24} intercept={cutoff} />
</feComponentTransfer>
<feComposite in="displaced" in2="mask" operator="in" />
</filter>
</defs>
</svg>
<AbsoluteFill style={{ filter: `url(#${filterId})` }}>{children}</AbsoluteFill>
</AbsoluteFill>
);
}
export function organicDissolve(props: OrganicDissolveProps = {}): TransitionPresentation<OrganicDissolveProps> {
return { component: OrganicDissolvePresentation, props };
}