【Remotion】Threeの画像初期フレーム欠落問題


こんにちは、フリーランスエンジニアの太田雅昭です。Remotionでの画像フィルタに関して書かせていただきます。

目的

動画作成ライブラリであるRemotion内で、画像に複雑なフィルタをかけたい。

Remotion

Remotionは、Reactコンポーネントを使って動画を作成できるライブラリです。従来の動画編集ソフトとは異なり、コードベースで動画を生成できるため、プログラマブルに動画制作が可能です。フレームごとにReactコンポーネントがレンダリングされ、それを連結することで動画が完成するという仕組みです。

画像フィルタの選択肢

Remotion + Context2Dのフィルター

Canvas 2D APIにはfilterプロパティが存在し、blur()brightness()などのフィルタを適用できます。しかし2026年1月29日現在このContext2DのフィルターはSafari未対応となっています。サーバーサイドでのレンダリングのみを行う場合は問題ありませんが、Remotion Playerを使用してブラウザ上でプレビューを確認する際には、この互換性の問題がネックとなってしまいます。

Remotion + Pixi.js

Remotion + Pixi.jsの選択肢もあるかと思います。ただこちらはRemotionの正式なライブラリがないことから、かなり難航します。どうしてもフィルタ+画像レンダリングまで至れなかったため、Remotionが公式に対応しているThree.jsを使用することにしました。

Remotion + Three.js

RemotionはThree.jsとの統合を公式にサポートしており、@remotion/threeパッケージが提供されています。このパッケージにはThreeCanvasコンポーネントが含まれており、Remotionのフレームレンダリングと同期した3D描画が可能となります。WebGLで複雑なフィルタ処理を実現できる上に、React Three Fiberのエコシステムも活用できるため、非常に強力な選択肢となります。

初期フレーム欠落問題

Remotion + Three.jsの構成で挑みましたが、初期フレームがどうしても欠落する問題がありました。理論的には、初期時にdelayRenderでストップし、TextureLoaderで読み込んだ後にcontinueRenderを呼び出す事で、正確に画像が表示されるはずですが、実際には0.5秒ほど欠落します。Threeのキャンバス描画タイミングが、どうしても合いませんでした。

Suspense + useLoaderでの対策

SuspenseとuseLoaderを使用する事で解決しました。以下のようにします。見通しのためプロパティなどは省略しています。

import { useLoader } from "@react-three/fiber";
import { ThreeCanvas } from "@remotion/three";
import { Suspense } from "react";
import { AbsoluteFill } from "remotion";
import * as THREE from "three";

export function ImageBackground() {
  // ...
  return (
    <AbsoluteFill>
      <Suspense fallback={null}>
        <ThreeCanvas>
          <ImageMesh />
        </ThreeCanvas>
      </Suspense>
    </AbsoluteFill>
  );
}

function ImageMesh() {
  const texture = useLoader(THREE.TextureLoader, src);

  return (
    <mesh>
      <planeGeometry />
      <meshBasicMaterial map={texture} />
    </mesh>
  );
}

useLoaderは画像が読み込まれるまでSuspendするため、それをSuspenseで待機する形ですね。これで初期フレーム欠落がなくなりました。

注意: Playerではちらつきが発生

上記の方法でRender処理の問題は解決しましたが、ブラウザでのPlayer問題が発生しました。例えば以下のようなコードです。

import { ThreeCanvas } from "@remotion/three";
import { AbsoluteFill, Sequence } from "remotion";

export function ThreeProblem() {
  return (
    <AbsoluteFill>
      <Sequence from={0} style={{ background: "green" }}>
        <AbsoluteFill></AbsoluteFill>
      </Sequence>
      <Sequence from={20}>
        <AbsoluteFill>
          {/* blue背景と時間差でredが表示されてしまう */}
          <ThreeCanvas
            orthographic={true}
            width={800}
            height={800}
            style={{ backgroundColor: "blue" }}
          >
            <mesh>
              <planeGeometry args={[200, 200]} />
              <meshBasicMaterial color="red" />
            </mesh>
          </ThreeCanvas>
        </AbsoluteFill>
      </Sequence>
    </AbsoluteFill>
  );
}

一見すると問題ない、決して重い処理でもないシンプルな実装ですが、シーン切り替え時にレンダリングのずれが生じます。これはSequenceが移るタイミングで再マウントされる関係から、Canvasの再生成に時間がかかるためのようです。

Sequenceのマウントタイミング

下記のコードでテストしてみます。

import { useEffect } from "react";
import { AbsoluteFill, Sequence } from "remotion";

export function MountTest() {
  return (
    <AbsoluteFill>
      <Sequence from={10} style={{ background: "blue" }}>
        <Test name="Blue" />
      </Sequence>
      <Sequence from={50} style={{ background: "green" }}>
        <Test name="Green" />
      </Sequence>
    </AbsoluteFill>
  );
}

function Test(props: { name: string }) {
  useEffect(() => {
    console.log(`Test ${props.name} mounted`);
    return () => {
      console.log(`Test ${props.name} unmounted`);
    };
  }, []);
  return <div style={{ fontSize: 50 }}>{props.name}</div>;
}

Remotion Studioでテストした結果、以下のようになりました。

  • 進む時はMountが蓄積される
  • 戻る時はUnmountされる

前述のThreeCanvasは進むときのみ(戻ってから進むのも含む)ちらつきが発生していたため、納得の結果です。このことから、Sequenceのアンマウントを防ぐ方法が有効かもしれません。メモリに関しては、もともと進む場合に限ってはMountが蓄積される仕様のため、あまり気にしなくていい可能性があります。

内部実装を伴う修正

以下では内部実装を使用した解決策が提示されています。

https://github.com/remotion-dev/remotion/issues/4201

ただしRemotionのバージョンアップに伴って使えなくなる可能性があり、メンテナンスに難があります。

薄いラッパーで対策

下記のようなラッパーで対策することもできます。

export function PreloadedSequence(
  props: Omit<SequenceProps, "from"> & {
    from: number;
    preloadFrames: number;
  },
) {
  const { from, preloadFrames, durationInFrames, children, ...rest } = props;
  const frame = useCurrentFrame();
  const isVisible = frame >= from;

  const actualFrom = Math.max(from - preloadFrames, 0);
  const actualPreloadFrames = from - actualFrom;

  return (
    <Sequence
      from={actualFrom}
      durationInFrames={durationInFrames ? durationInFrames + actualPreloadFrames : undefined}
      {...rest}
    >
      <div
        style={{
          opacity: isVisible ? 1 : 0,
          pointerEvents: isVisible ? "auto" : "none",
        }}
      >
        {children}
      </div>
    </Sequence>
  );
}

これを通常のSequenceの代わりに使用することで、実際のレンダリングより前にpreloadするとができます。ただ最初の1シーンはプリロードできないため、前述のSuspense + useLoaderが必要になってきそうです。