RemotionのLoopはコンポジション絶対フレームで位相を計算する──カード内の動画クリップが登場した瞬間に終盤から再生される原因と直し方
Remotion 4.0.474で、Loopコンポーネントの周回位相は子の登場フレームではなくコンポジションの絶対フレームで決まる。durationInFrames=240のLoopをコンポジションのフレーム900で見ると、子が受け取るuseCurrentFrame()は0ではなく180(クリップの75%地点)で、出た瞬間に終盤から再生される。Sequenceのfrom=900・layout=noneで包むと0になることを、2026-08-14にmacOS 15.1.1・remotion 4.0.474・remotion stillで実測した記録。composition idの正規表現と、src配下を.jsにしたときのwebpack parse errorも同じ環境で再現している。
結論から書く。Remotionの <Loop> は、周回の位相(何周目のどこを再生するか)を「子が登場したフレーム」ではなく「コンポジションの絶対フレーム」から計算する。だから動画クリップを <Loop> で包んでカードの中に出すと、そのカードが出た瞬間にクリップが頭からではなく途中から──運が悪いと終盤から──再生される。直し方は一つで、<Sequence from={登場フレーム} layout="none"> で <Loop> を包む。これで子が受け取る useCurrentFrame() の起点が登場フレームになり、位相が0から始まる。実測では、durationInFrames={240} の <Loop> をコンポジションのフレーム900で見たとき、素のままだと子は 180(クリップの75%地点)を受け取り、<Sequence from={900} layout="none"> で包むと 0 になった(2026-08-14、remotion 4.0.474、macOS 15.1.1)。
3行まとめ
<Loop>の位相はuseCurrentFrame()から計算される。<Sequence>の外では、これはコンポジションの絶対フレームなので、クリップの登場位置に無関係な地点から再生が始まる- 直し方は
<Sequence from={enterFrame} layout="none">で<Loop>を包むこと。layout="none"を付けないと<AbsoluteFill>が挟まってカード内のフロー配置が壊れる- 同じRemotion案件でハマった周辺2件(composition idに
_が使えない/src配下を.jsにするとModule parse failed: 'import' and 'export' may appear only with 'sourceType: module')も、同じ環境でエラー文字列ごと再現した
症状
短いクリップなら気づかない。8秒程度のクリップだと「なんか動きが変だな」で流れてしまう。55秒級の録画を差し込んだときに、登場した瞬間から終わりかけの映像が流れるという形ではっきり出た。
再現用の最小コンポジションを置く。子が受け取ったフレーム番号をそのまま画面に描くだけのものだ。
import React from 'react';
import {AbsoluteFill, Composition, Loop, Sequence, useCurrentFrame} from 'remotion';
const Child = ({label}) => {
const f = useCurrentFrame();
return (
<AbsoluteFill style={{background: 'white', color: 'black', fontSize: 90,
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace'}}>
{label}={f}
</AbsoluteFill>
);
};
// 素の <Loop>(Sequence で包まない)
const Bare = () => (
<Loop durationInFrames={240} layout="none">
<Child label="BARE" />
</Loop>
);
// <Sequence from={900}> で包んだ <Loop>
const Wrapped = () => (
<Sequence from={900} layout="none">
<Loop durationInFrames={240} layout="none">
<Child label="WRAP" />
</Loop>
</Sequence>
);
export const RemotionRoot = () => (
<>
<Composition id="Bare" component={Bare} durationInFrames={1200} fps={30} width={640} height={360} />
<Composition id="Wrapped" component={Wrapped} durationInFrames={1200} fps={30} width={640} height={360} />
</>
);
npx remotion still src/index.jsx Bare out.png --frame=900 のように、フレームを指定して1枚だけ書き出して読む。フルレンダを回さずに位相を確認できる。実測値は次の通り。
| コンポジションの絶対フレーム | 子が受け取る useCurrentFrame() |
240フレーム中の位置 |
|---|---|---|
| 239 | 239 | 99.6% |
| 240 | 0 | 0% |
| 900 | 180 | 75.0% |
| 1000 | 40 | 16.7% |
同じフレーム900でも、<Sequence from={900} layout="none"> で包んだ Wrapped は 0 を返した。
つまり、素のままだと「クリップが画面に出た時刻」と「クリップの再生位置」が無関係に決まる。900 ÷ 240 = 3.75 の小数部 0.75 がそのまま再生位置になっている、と読むと分かりやすい。
原因
1. <Loop> は useCurrentFrame() の値で何周目かを決めている
remotion 4.0.474 の同梱実装(node_modules/remotion/dist/cjs/loop/index.js)に、そのままの計算が書かれている。
const currentFrame = useCurrentFrame();
// ...
const maxFrame = durationInFrames * (actualTimes - 1);
const iteration = Math.floor(currentFrame / durationInFrames);
const start = iteration * durationInFrames;
const from = Math.min(start, maxFrame);
(同梱の .js はトランスパイル後のCJSなので、実際の呼び出しは (0, use_current_frame_js_1.useCurrentFrame)() のように名前空間の接頭辞が付く。上の引用は読みやすさのためその接頭辞だけ外した。後半4行の計算式は、公式ドキュメントがリンクしているTypeScript原典 packages/core/src/loop/index.tsx の該当行とも文字どおり一致する)
そしてこの from を持った <Sequence> で子を包んで返している。iteration は currentFrame を durationInFrames で割った商なので、currentFrame が何を基準にした値なのかで位相が丸ごと変わる。
2. useCurrentFrame() の基準は「一番近い <Sequence>」
<Sequence> の公式ドキュメントにこう書かれている。
All children of a
<Sequence>that calluseCurrentFrame()will receive a value that is shifted byfrom.(訳:
useCurrentFrame()を呼ぶ<Sequence>のすべての子は、fromの分だけシフトされた値を受け取る。)
同ページには、フレーム30の時点で <Sequence from={30}> の外と中がそれぞれ何を返すかの例も載っている。
At frame
0, this would render<div>0</div>. At frame30, this would render<div>30</div><div>0</div>.(訳:フレーム0では
<div>0</div>がレンダされる。フレーム30では<div>30</div><div>0</div>がレンダされる。)
実装側(node_modules/remotion/dist/cjs/use-current-frame.js)も同じことをしている(こちらも上と同じく名前空間の接頭辞を外し、三項演算子を1行にまとめてある)。
const frame = useTimelinePosition();
const context = useContext(SequenceContext);
const contextOffset = context ? context.cumulatedFrom + context.relativeFrom : 0;
return frame - contextOffset;
SequenceContext が無ければ contextOffset は0、つまりコンポジションの絶対フレームがそのまま返る。<Loop> を <Sequence> の外に置くと、<Loop> 自身が読む currentFrame は絶対フレームになり、上の Math.floor(currentFrame / durationInFrames) が「クリップの登場時刻」を一切考慮しない値になる。これが位相ズレの正体だ。
3. layout の既定値は absolute-fill
<Loop> の公式ドキュメントの layout? プロップの記述はこうだ。
layout?— Either"absolute-fill"or"none". Default:"absolute-fill". By default, your content will be absolutely positioned. If you would like to disable layout side effects, passlayout="none".(訳:
layout?—"absolute-fill"か"none"のいずれか。既定は"absolute-fill"。既定では、コンテンツは絶対配置される。レイアウトの副作用を無効にしたい場合はlayout="none"を渡す。)
<Sequence> 側にも対になる記述がある。
By default, the children of a
<Sequence>are wrapped in an<AbsoluteFill>component. If you don't want this behavior, addlayout="none"as a prop.(訳:既定では、
<Sequence>の子は<AbsoluteFill>コンポーネントで包まれる。この挙動が不要なら、プロップとしてlayout="none"を付ける。)
<Loop> は内部で <Sequence> を返しているので、この副作用は <Loop> にもそのまま乗る。カードの中に見出しとクリップを縦に並べているようなフロー配置だと、<AbsoluteFill> がカードの内側いっぱいに広がって見出しを覆い、padding を無視する。
同じカードを layout 既定と layout="none" で1枚ずつ書き出して比べたところ、既定側ではカード上部の見出しテキストが完全に隠れ、クリップがカードの角(padding の外側)から始まっていた。layout="none" 側では見出しの下にクリップが収まった。
対処
① <Loop> を <Sequence from={登場フレーム} layout="none"> で包む
これが本題の修正。手元の実装(src/ui.jsx の VideoClipLayer)では、スライドの走査で求めた enterFrame をそのまま from に渡している。
{/* Sequence from=enterFrame: 位相をクリップ登場時点起点にする */}
<Sequence from={enterFrame} layout="none">
{/* ここに <Loop> や <OffthreadVideo> を置く */}
</Sequence>
layout="none" は両方(外側の <Sequence> にも <Loop> にも)必要だ。片方でも既定のままだと <AbsoluteFill> が1枚挟まる。
② そもそも <Loop> を <Sequence> の中に置く設計にする
ショート動画側の実装では、各スライドが最初から <Sequence key={i} from={s.start} durationInFrames={s.dur}> の中にある。この構造なら <Loop> はスライド開始フレーム基準で回るので、位相ズレは起きない。**「<Loop> を裸でコンポジション直下に置かない」**を設計上のルールにしてしまうのが確実だと考えている。
③ 検証はフルレンダではなく remotion still で
位相の確認にフルレンダは要らない。
npx remotion still src/index.jsx MyComp out/still_test.png --frame=900
1フレームだけ書き出して目視する。当方の作業記録では、フルレンダに20分かかるコンポジションでも1フレームなら1分程度で済んでいる。そのため位相・レイアウト系の確認はほぼこれで通している。
同じ案件で踏んだ周辺2件(エラー文字列そのまま)
貼って検索されるのはエラー文字列だと思うので、同じ環境で再現して原文のまま置く。
composition id にアンダースコアは使えない
id="Probe_Comp" にして npx remotion compositions を叩くと、ブラウザ側の例外がそのままCLIに出る。
Error: Composition id can only contain a-z, A-Z, 0-9, CJK characters and -. You passed Probe_Comp
at validateCompositionId (http://localhost:3000/bundle.js:2297:11)
(実際の1行目には [Tab 0, ../../…/node_modules/react-dom/cjs/react-dom.production.min.js:187] というタブ位置の接頭辞が付く。上ではそれを外している。Error: 以降と at validateCompositionId の行は出力のままである。この後にブラウザ終了に伴う ProtocolError: Protocol error (Target.closeTarget): Target closed. も続けて出た)
公式ドキュメント(<Composition> の id)の記述は次の通りで、CJKには触れていない。
ID of the composition, as shown in the sidebar and also a unique identifier of the composition that you need to specify if you want to render it. The ID can only contain letters, numbers and
-.(訳:コンポジションのID。サイドバーに表示され、レンダリングしたい場合に指定する必要がある一意な識別子でもある。IDには英字・数字・
-しか使えない。)
実際に判定している正規表現は同梱実装(node_modules/remotion/dist/cjs/validation/validate-composition-id.js)にあり、CJK範囲が明示的に許可されている。
const getRegex = () => /^([a-zA-Z0-9-\u4E00-\u9FFF])+$/g;
_(アンダースコア)も .(ドット)もこの範囲にない。手元では AijitanLong / AijitanShort のようにキャメルケースで通している。
src配下で import / export を書くファイルは .jsx にする
src/index.js と src/Root.js(中身は同じ、拡張子だけ .js)でバンドルすると、こうなる。
Error: Module parse failed: 'import' and 'export' may appear only with 'sourceType: module' (1:0)
File was processed with these loaders:
* ./node_modules/@remotion/bundler/dist/esbuild-loader/index.js
You may need an additional loader to handle the result of these loaders.
> import { registerRoot } from "remotion";
| import { RemotionRoot } from "./Root.js";
| registerRoot(RemotionRoot);
(ローダーのパスだけ読みやすさのため短縮している。実際の出力には、検証用ディレクトリから見た ../../../../../../../Users/.../node_modules/@remotion/bundler/dist/esbuild-loader/index.js という長い相対パスが入る。それ以外の行は出力のままである。)
2つのファイルを .jsx にリネームし、index 側の import 先も "./Root.js" → "./Root.jsx" に書き換えるとバンドルが通り、The following compositions are available: まで到達した。import 先の書き換えは省略できない。@remotion/bundler の resolve.extensions は ['.ts', '.tsx', '.web.js', '.js', '.jsx', '.mjs', '.cjs'] で、"./Root.js" という拡張子つきの明示指定は Root.jsx にフォールバックせず Root.js doesn't exist で落ちる(同環境で確認済み)。切り分けとして、この2回は同じディレクトリで、拡張子と import 先の文字列だけを変えて実行している。
噛み合っているのは、プロジェクトの package.json に "type": "commonjs" があること(当方の motion_pipeline はこれ)と、@remotion/bundler のwebpack設定が .js も .jsx も同じルールで拾っていること(node_modules/@remotion/bundler/dist/webpack-config.js の test: /\.jsx?$/, exclude: /node_modules/)だ。esbuild-loaderがJSXを変換したあとも import / export はESMのまま残るため、.js はCommonJSとして解釈され、その import が構文エラーになる。.jsx にすれば "type" を触らずに済むので、当方はそちらを取っている。
OffthreadVideo の width / height について(再現できなかったので正直に書く)
手元の実装では、<OffthreadVideo> に maxWidth ではなく、動画の実寸から計算した width / height を明示的に渡している。
// 実寸から表示サイズを明示計算(高さ622基準・幅は最大1280でクランプ)
const vw = cur.videoW || 1920, vh = cur.videoH || 1080;
let dispH = 622, dispW = Math.round(622 * vw / vh);
if (dispW > 1280) { dispW = 1280; dispH = Math.round(1280 * vh / vw); }
// ...
<OffthreadVideo src={staticFile(`videos/${cur.video}`)} muted
style={{width: dispW, height: dispH, objectFit: 'cover'}} />
当時の作業メモには「maxWidth だけだと描画されない」と残している。ただし、2026-08-14に最小構成で再現を試したところ、maxWidth だけでも描画された。maxWidth: 400 のみ、maxWidth: 1280 + maxHeight: 400 のみ、maxWidth: '100%' + maxHeight: 400 の3パターンをカード枠の中で remotion still に掛けたが、いずれも正常に表示されている。したがって「maxWidth だけでは描画されない」を一般則として書くことはできない。当時の環境固有の要因(親要素の指定、Remotionのバージョン差など)を切り分けられていない。
一般則として書けるのは、公式ドキュメントに書かれているこの一点だけだ。
The props
onError,classNameandstyleare supported and get passed to the underlying HTML element. Remember that during render, this is a<img>element, and during Preview, this is a<video>element.(訳:
onError・className・styleの各プロップはサポートされており、下層のHTML要素へ渡される。レンダリング中はこれが<img>要素で、プレビュー中は<video>要素であることに注意。)
つまり <OffthreadVideo> はプレビューとレンダで別のHTML要素になる。要素の内在サイズ(intrinsic size)に依存する組み方をすると、プレビューとレンダで結果が変わる余地が残る。width / height を明示するのは、その依存を消しておくための保険という位置づけで書いている。「必須」とまでは、今回の再現では言えなかった。
踏んだ記録
- 位相バグが顕在化した日: 2026-07-12。案件動画の確認中に、カード内のクリップが登場と同時に終盤から流れているのを発見した。それまで差し込んでいたクリップは8秒級で、位相ズレが「少し途中から」にしか見えず気づけていなかった。55秒の録画を入れた回で初めてはっきり出た(クリップ再生機能を実装したのは2026-07-03なので、潜伏期間は9日)
- 記事用の再検証日: 2026-08-14。本記事に載せた数値・エラー文字列は、この日にすべて実行し直して採った
- 環境: macOS 15.1.1(BuildVersion 24B91)/Node.js v25.9.0/npm 11.12.1/remotion 4.0.474、@remotion/cli 4.0.474、@remotion/bundler 4.0.474、react 18.3.1、webpack 5.105.0、esbuild 0.28.0
- 位相の測り方: 上記の最小コンポジション(1200フレーム/30fps/640×360)を
npx remotion stillで4フレーム分(239・240・900・1000)書き出し、画像に描かれた数値を読んだ。Wrapped側はフレーム900のみ書き出して0を確認 - レイアウト副作用の測り方: 白いカード(
width: 424, height: 300, padding: 12、上部に見出しテキスト)の中に、<Loop durationInFrames={60}>の既定とlayout="none"を1枚ずつ書き出して比較。既定側では見出しが隠れ、layout="none"側では見出しの下に収まった .js/.jsxの切り分け: 同じディレクトリで2回だけ実行した。src/index.js+src/Root.jsで parse error、2ファイルを.jsxにリネームしindexの import 先を"./Root.jsx"に直して再実行で成功。変えたのはこの2点(ファイル拡張子と import 先の文字列)だけで、コンポーネントのコードは触っていない- composition idの切り分け:
id="ProbeComp"で成功したものをid="Probe_Comp"に変えて再実行し、上記のエラー文字列を取得。1回 - 回数の限界:
.js/ composition id / レイアウト副作用は、それぞれ上記の回数しか実行していない。当日1回ずつの再現であって、多数回の統計ではない
正直に書くと、このバグは2026-07-03にクリップ再生機能を入れた時点から実装に入っていて、見つけたのは7月12日だ。その間に作った動画で実際に位相ズレが出ていたかどうかは、遡って確認していない。8秒級のクリップだと「少し途中から始まる」程度にしか見えないので、当時の確認では見逃していた。長い素材を入れて初めて表面化するタイプのバグは、短い素材でのQAをすり抜ける。
正直な但し書き
- 本記事の実測はすべて remotion 4.0.474 のもの。
<Loop>の位相計算は同梱ソースを直接読んで確認しているが、将来のバージョンで実装が変わる可能性はある <Loop>のドキュメント自体には「位相がコンポジションの絶対フレーム基準である」という直接の記述はない。本記事はドキュメントの<Sequence>/useCurrentFrame()の記述と、同梱実装のコード、およびremotion stillの実測を突き合わせて書いている- 「
<OffthreadVideo>はmaxWidthだけでは描画されない」は再現できなかったため、一般則として書いていない(前掲の節を参照)。当時の作業メモにその記述が残っていることだけが事実で、原因は特定できていない - composition idの正規表現に含まれる CJK 範囲は
\u4E00-\u9FFFで、これはCJK統合漢字の基本ブロックにあたる。ひらがな・カタカナはこの範囲外だが、実際に日本語かなのidで試してはいない - レイアウト副作用の比較は、当方が組んだカード構造での結果である。別のCSS構造では見え方が変わる
- 長尺レンダそのものが完走しない場合は原因が別(メモリ枯渇)で、そちらはRemotionで長い動画のレンダリングがクラッシュするに分けて書いている
出典
<Loop>| Remotion公式ドキュメント —layoutプロップの既定値が"absolute-fill"である旨、layout="none"でレイアウト副作用を無効化できる旨(2026-08-14取得。ページ末尾の Last updated は Aug 12, 2026)<Sequence>| Remotion公式ドキュメント —fromによって子のuseCurrentFrame()がシフトされる旨、既定で子が<AbsoluteFill>に包まれる旨(同上)<OffthreadVideo>| Remotion公式ドキュメント — レンダ時は<img>、プレビュー時は<video>になる旨(同上)<Composition>| Remotion公式ドキュメント —idに使える文字(同上)- remotion 4.0.474 同梱ソース(手元の
node_modulesから引用)—dist/cjs/loop/index.jsの位相計算、dist/cjs/use-current-frame.jsのSequenceContextオフセット、dist/cjs/validation/validate-composition-id.jsの正規表現とエラー文言、@remotion/bundler/dist/webpack-config.jsのtest: /\.jsx?$/
出典・参照資料
- 一次資料<Loop> | Remotion公式ドキュメント(layoutプロップの既定値と挙動) ↗
- 一次資料<Sequence> | Remotion公式ドキュメント(fromによるuseCurrentFrame()のシフト、既定のAbsoluteFillラップ) ↗
- 一次資料<OffthreadVideo> | Remotion公式ドキュメント(レンダ時はimg・プレビュー時はvideo) ↗
- 一次資料<Composition> | Remotion公式ドキュメント(idに使える文字) ↗
- 一次資料remotion-dev/remotion — packages/core/src/loop/index.tsx(Loopコンポーネントのソース。<Loop>公式ドキュメントの Source code for this component リンク先。引用は手元にインストールされた remotion 4.0.474 の node_modules/remotion/dist/cjs/loop/index.js から取得) ↗
AIニュースの解説を動画でも
YouTubeでは注目ニュースの背景を解説し、Xでは新着記事をお知らせしています。
コメント
まだコメントはありません。最初のコメントを書いてみませんか?
AIについて聞きたいことはありますか?
質問箱で無料で受け付けています。回答は公開され、他の方の参考にもなります。
質問箱を見る →新しい記事をメールで受け取る
AIの新しい発表を、出典付きで整理して届けます。