ループ動画のつなぎ目が、なんとなく「カクッ」として見えました。測ってみると、つなぎ目の前後のコマの差は11。動画の中でいちばん大きく動いた所でも3.2。つなぎ目だけ、約3.4倍も跳んでいました。
使っていたのは、何十本ものループ動画を作ってきた、いつもの道具です。毎回うまくいっているように見えたので、一度も測ったことがありませんでした。原因を追いかけると、ffmpegのxfade(溶かし合わせ)の仕様を知らずに、ループの切れ目を置いていたことが分かりました。この記事では、つなぎ目の測り方、xfadeの落とし穴、直し方(短いスクリプトつき)を置いておきます。
何が起きたか:いつもの道具で、つなぎ目だけ数字が高い
うちでは、数秒の動画を「ユニット」にして、つなぎ目をディゾルブ(溶かし合わせ)でなめらかにしてから、何時間ぶんも繰り返しています。素材は、画像のAIで描いた1枚の絵を、動画のAI(Kling)で数秒だけ動かしたものです。
その日の素材は、いつもより動きが多めでした。そのせいか、つなぎ目で一瞬「カクッ」とした気がしました。気のせいかどうかを決めるために、隣り合うコマどうしの差を全部測りました。
| 場所 | 隣のコマとの差 |
|---|---|
| ユニットの中(いちばん大きい所) | 3.2 |
| つなぎ目 | 11 |
ループ動画のつなぎ目の測り方:つなぎ目の差と、中の最大を並べる
やることは2つだけです。ループした動画から、隣り合うコマの明るさの差(画素ごとの差の平均)を全部出します。そして、つなぎ目の差と、つなぎ目以外でいちばん大きい差を並べます。うちで使っている短いスクリプトを置いておきます。
# tsunagime_check.py(抜粋)
import cv2, numpy as np
cap = cv2.VideoCapture("loop.mp4")
frames = []
while True:
ok, f = cap.read()
if not ok:
break
frames.append(cv2.cvtColor(f, cv2.COLOR_BGR2GRAY).astype(np.float32))
diffs = [np.abs(frames[i+1] - frames[i]).mean() for i in range(len(frames)-1)]
seam = 120 # つなぎ目のコマ番号(ユニットのコマ数)
print("つなぎ目", round(diffs[seam-1], 1))
print("中の最大", round(max(d for i, d in enumerate(diffs) if i != seam-1), 1))
読み方は単純です。つなぎ目が中の最大と同じくらいなら合格。はっきり大きければ、そこで絵が跳んでいます。
次に、つなぎ目の前後のコマが、元の動画の何コマ目に近いかを調べました。すると、ユニットの頭は元の36コマ目、尻は34コマ目にいちばん近い状態でした。34コマ目から36コマ目へ、間を飛ばして跳んでいたことになります。
ffmpegのxfadeでループを作るときの落とし穴:100%のコマは切り替えの外にある
xfadeが、溶かし合わせの間にどんな割合で絵を混ぜているのかを確かめました。真っ黒の動画から真っ白の動画へ、1秒(30コマ)かけて溶かし、1コマずつ明るさを測ります。
ffmpeg -f lavfi -i color=black:s=64x64:r=30:d=2 \
-f lavfi -i color=white:s=64x64:r=30:d=2 \
-filter_complex "[0][1]xfade=transition=fade:duration=1:offset=1,format=gray" \
-f rawvideo bw.raw
| コマ | 白の割合 |
|---|---|
| 切り替えの1コマ目 | 0% |
| 2コマ目 | 3.1% |
| … | 1コマごとに約3.3%ずつ増える |
| 切り替えの最後(30コマ目) | 96.5% |
| 切り替えが終わった次のコマ | 100% |

xfadeの切り替えの間、混ぜる割合は0/30から29/30まで進みます(29/30は約96.7%。明るさは0〜255の整数なので、測ると96.5%になります。ffmpeg 8.1.1で確認)。次の絵が100%になるのは、切り替えが終わった次のコマです。これは不具合ではなく、そういう仕様です。2本の動画をふつうにつなぐなら、そのまま次の絵が続くので、何の問題もありません。
問題は、ループです。うちの道具は、切り替えの最後のコマ(約97%)をユニットの尻にして、頭へ戻していました。本来その次に来るはずの「100%のコマ」は、ユニットに入っていません。これで1コマぶん、つなぎ目で絵が跳びます。
もう1つは素材の側です。動画のAIで作った素材は、出だしの2コマほどが小さく揺れることがあります。今回の残りの1コマぶんは、この揺れが重なったものと見ています(ここは確かめきれていません)。
直し方:重みを自分で決めて、最後のコマで溶かしきる
直し方は、溶かす割合を道具まかせにせず、自分で決めることです。ディゾルブがXコマなら、j番目のコマ(0から数える)の「次の絵の割合」を(j+1)/Xにします。こうすると、最後のコマ(j = X−1)でちょうど1、つまり切り替えの中で次の絵が100%になります。
# 溶かし合わせを自前でやる(抜粋)
import numpy as np
def dissolve(tail, head):
# tail, head: 同じ枚数(X枚)のコマのリスト(numpy配列)
X = len(tail)
out = []
for j in range(X):
w = (j + 1) / X # 最後のコマで、ちょうど1になる
out.append((1 - w) * tail[j] + w * head[j])
return out
あわせて、動画のAIで作った素材の頭の2コマは捨ててからユニットにします。直したあと、同じ測り方でもう一度測りました。
| つなぎ目 | 中の最大 | |
|---|---|---|
| 直す前 | 11 | 3.2 |
| 直した後 | 3.3 | 3.2 |
つなぎ目が、中で動いている所とほぼ同じになりました。目で見ても、もうつなぎ目は分かりません。それ以来、うちのループ動画は全部この作り方にしています。
「いつも通っている道具」ほど、測らない
この道具は、何十本ものループ動画で使ってきました。そのあいだ、一度もつなぎ目を数字で見ていません。毎回うまくいっている「ように見えた」からです。
今回たまたま、動きの多い素材で「カクッ」が目に見える大きさになり、初めて測りました。新しく作った道具は疑うのに、使い慣れた道具は疑わない。見た目で分からない程度のずれは、慣れた道具の中にこそ残っているのだと思います。
まとめ
- ループのつなぎ目は、つなぎ目の差と中の最大の差を並べると、良し悪しが数字で分かる
- 今回はつなぎ目11・中の最大3.2で、2コマぶん跳んでいた
- ffmpegのxfadeは、切り替えの最後のコマでも約97%(30コマの場合は29/30)。100%は切り替えの外にある
- そのコマをループの尻にすると、100%のコマを落として跳ぶ
- 直し方は、重みを(j+1)/Xにして最後のコマで溶かしきる+動画のAIの素材の頭2コマを捨てる
- 直した後はつなぎ目3.3。慣れた道具ほど、一度は測る
動画のAIで「ここだけ動かして」が効いたかを測る方法は、動画AIで「ここだけ動かして」が効かないときの見分け方にまとめています。

