ループ動画の「つなぎ目」で絵が飛ぶのを、AIのテスト係が数字で見つけてくれた
AI
Claude
GLSL
はじめに
配信の待機画面っぽい、ずっとくり返して流せる背景動画を作って遊んでいます(前回のBGMの記事と同じ遊びです)。
宇宙で惑星が回っていたり、深海で泡が上がっていったりする映像を、GLSL(シェーダー)で描いて、32秒の動画に書き出しています。
32秒たったら最初に戻るので、「最後のコマ」と「最初のコマ」がなめらかにつながっていないと、くり返すたびに絵がパッと飛びます。
できあがった動画のテストには、AI(Claude)を使ってみました。「テスト係」として、動画を渡して確かめてもらうやり方です。
自分では「ループは継ぎ目なしでつながっている」つもりで、何度か見て「いい感じ」と思っていました。
ところが、テスト係から返ってきたのはこんな報告でした。
言われてからつなぎ目をじっと見ると、たしかに飛んでいます。
この記事では、テスト係がやっていた「つなぎ目を数字で確かめる方法」と、見つかった2つのズレの原因・直し方を、記事用に作った小さな見本で再現しながら紹介します。
ループの「つなぎ目」とは
32秒・30fpsの動画は 960コマです。再生すると 0, 1, 2, …, 959 と進んで、959 の次にまた 0 に戻ります。
つまり、ふつうのコマ送り(たとえば 100→101)と同じように、「959→0」もなめらかでないといけません。この「959→0」がつなぎ目です。
シェーダー側では、ループの中の位置を ph() (0〜1)として、動きは全部 ph() から作っています。
float ph(){ return fract(uTime / L); } // L = 32.0(秒)。0 → 1 で1ループ
// 例: 1ループで2回ゆれる
float sway = sin(TAU * ph() * 2.0);sin(TAU * ph() * 整数) のように、1ループでちょうど整数回まわる動きなら、ph=1 と ph=0 で同じ値になるので、つなぎ目はなめらかです。
問題は、この「ちょうど整数回」になっていない動きが、コードのどこかに1つでも混ざっていたときです。
テスト係の確かめ方:となりのコマとの差を全部はかる
テスト係がやっていたのは、とてもシンプルなことでした。
1.動画を全部のコマに分ける
2.となりのコマどうし(0→1、1→2、…)の差をはかる
3.最後に「959→0」の差もはかり、ふだんの差と比べる
つなぎ目がなめらかなら、959→0 の差は、ほかのコマ送りと同じくらいになるはずです。飛んでいれば、そこだけ飛び出ます。
記事用の見本(4秒・120コマ)で、実際にはかったのがこのグラフです。
ほとんどのコマ送りは平らで、つなぎ目だけがピンと立っています。こうなっていれば、目で見なくても「ここで飛んでいる」と言えます。
差がどこに出ているかを画像にすると、原因の当たりもつけられます。テスト係の報告に「差のほとんどが惑星の上にある」と書いてあったのは、これを見ていたからです。
「最後の時刻と最初の時刻を比べる」ではダメな理由
最初、自分は「32秒のコマと0秒のコマを比べれば一発では?」と思いました。でも、これはうまくいきません。
| 比べ方 | 何を比べているか | 結果 |
|---|---|---|
| t=32秒 と t=0秒 | ph() は fract(t/32) なので、t=32 は t=0 とまったく同じ絵 | 差は必ず0。ズレがあっても合格に見える |
| 最後のコマ(959)と最初のコマ(0) | 実際に画面で続けて出る2枚 | ズレがあれば差が出る |
| 上の差を、となりのコマどうしの差と比べる | 「ふだんどれくらい動くか」を基準にする | 動きの多い映像でも、飛びだけを見分けられる |
ループでは「32秒目」というコマは再生されません。再生されるのは 959 の次の 0 です。だから、比べるのは 959 と 0 です。
もう1つ大事なのが3行目です。泡のように毎コマ大きく動く映像は、もともとの差が大きいので、つなぎ目の差の大きさだけを見ても判断できません。「ふだんの何倍か」で見ます。
ズレ1:惑星が半回転しかしていなかった
1つ目の原因は、惑星の回し方でした。テスト係の報告を、見本で再現したのがこちらです。
直す前のコードは、だいたいこうなっていました。
// 直す前
float lon = atan(n.x, n.z) + TAU * ph() * 0.5; // 1ループで半回転
float m = fbm(vec2(lon * 1.2, n.y * 4.0)); // 経度をそのままノイズに入れるズレの原因は2つ重なっていました。
| 原因 | 何が起きるか |
|---|---|
| TAU * ph() * 0.5 で1ループに半回転 | ループの最後で、惑星は反対側を向いている。最初のコマに戻ると、くるっと半回転分ワープする |
| 経度 lon をそのまま fbm に入れている | 経度が一周(2π)しても、ノイズの模様は一周でそろわない。回転を1回転にしても模様がつながらない |
2つ目が気づきにくいところです。ノイズ関数は、入力が 2π ずれても同じ値を返してはくれません。
そこで、経度を cos と sin に変えてからノイズに入れるようにします。 cos と sin は一周すると必ず同じ値に戻るので、模様も一周でつながります。
// 直したあと
float lon = atan(n.x, n.z) + TAU * ph(); // 1ループでちょうど1回転
vec2 lc = vec2(cos(lon), sin(lon)); // 一周すると同じ値に戻る
float m = fbm(vec3(lc * 1.5, n.y * 4.0)); // 円の上の点でノイズを引く「角度をノイズに入れるときは、角度そのものではなく円の上の点にする」は、ループに限らず、球や円柱に模様を貼るときにも使える考え方です。
ズレ2:泡のマスの番号が、1ループでずれていた
2つ目は深海の泡です。泡は、画面をマス目に分けて、マスごとに「泡があるか・大きさ・位置」を乱数で決めて描いています。
マス目全体を上に流すと、泡が上がっていくように見えます。
// 直す前
vec2 g = uv * sc;
g.y -= ph() * N; // 1ループで N マス分、上に流す
vec2 id = floor(g); // マスの番号
float r = h21(id); // 番号から乱数(泡の有無・大きさ・位置)1ループで N マス流れると、同じ場所に来るマスの番号は N だけずれます。番号が変われば乱数も変わるので、最後のコマと最初のコマで、同じ場所にある泡がまったく別の泡になってしまいます。
直し方の案も、テスト係の報告に書いてありました。番号を N でわった余りにしてから乱数を引きます。
// 直したあと
float N = floor(L * speed * sc); // 1ループで流れるマスの数(整数にしておく)
vec2 g = uv * sc;
g.y -= ph() * N;
vec2 id = floor(g);
id.y = mod(id.y, N); // N マスごとに同じ番号 → 1ループ後に同じ泡が戻る
float r = h21(id);注意点は、N が画面に見えているマスの数より少ないと、同じ並びの泡が縦にくり返して見えてしまうことです。見本では、画面に5段見えているところを、1ループで8段流すようにしています。
本物の動画では、どれくらい変わったか
見本ではなく、実際に作っていた背景動画でのテスト係の数字です。直したあとの再テストでは、平均の差に加えて「大きく変わる画素(差が40以上、960×540に縮めて)」の数も数えていました。
| 背景 | 直す前(つなぎ目 / ふだん) | 直したあと(大きく変わる画素) | 判断 |
|---|---|---|---|
| 宇宙(回る惑星) | 平均の差 2.5 / 0.3(約8倍) | 4,364 → 155(ふだん 141〜159) | 直す |
| 深海(泡) | 平均の差 1.7 / 0.57(約3倍) | 4,889 → 71(ふだん 58〜85) | 直す |
| 夜の街(雨・ネオン) | 平均の差 0.8 / 0.16(約5倍) | — | 直さなくてよい |
夜の街の判断がおもしろいところです。数字だけ見るとつなぎ目の差は5倍あるのに、テスト係は「差の中身は雨の線とビルの反射のちらつきで、ふだんから毎コマ変わるものなので目立たない」として、直さなくてよいと書いていました。
数字で見つけて、差の画像で中身を確かめて、直すかどうかを決める。この順番がよかったです。
もう1つ、直したあとの宇宙の背景は、平均の差だけはつなぎ目で少し大きいままでした(1.14 対 0.5 前後)。これも、テスト係は「大きく変わる画素は増えていないので、動画の圧縮(先頭のキーフレーム)による細かい差で、目では見えない」と切り分けていました。平均だけで判定していたら、ここで迷っていたと思います。
自分で確かめるとき用のコード
同じ確かめ方を、numpy と ffmpeg だけで書くとこうなります。動画のファイルを渡すと、つなぎ目の差がふだんの何倍かを出します。
import subprocess, sys
import numpy as np
W, H = 320, 180 # 小さくして比べる(速い・圧縮の細かいノイズも減る)
def load_gray(path):
raw = subprocess.run(
["ffmpeg", "-v", "error", "-i", path, "-vf", f"scale={W}:{H}",
"-f", "rawvideo", "-pix_fmt", "gray", "-"],
capture_output=True, check=True).stdout
return np.frombuffer(raw, np.uint8).reshape(-1, H, W).astype(np.int16)
f = load_gray(sys.argv[1])
nxt = np.roll(f, -1, axis=0) # i の次のコマ。最後のコマの次は最初のコマ
d = np.abs(nxt - f) # (コマ数, H, W)
mean = d.mean(axis=(1, 2)) # 平均の差
big = (d > 40).sum(axis=(1, 2)) # 大きく変わった画素の数
seam_m, seam_b = mean[-1], big[-1] # 最後 → 最初 = つなぎ目
norm_m = np.median(mean[:-1])
norm_b = np.percentile(big[:-1], 95)
print(f"コマ数 {len(f)}")
print(f"平均の差 つなぎ目 {seam_m:.2f} / ふだん {norm_m:.2f}({seam_m / max(norm_m, 1e-6):.1f}倍)")
print(f"大きく変わる画素 つなぎ目 {seam_b} / ふだん(95%点) {norm_b:.0f}")
print("NG: つなぎ目で絵が飛んでいそう" if seam_b > norm_b * 1.5 + 50 else "OK")ポイントは np.roll(f, -1, axis=0) の1行です。コマを1つずらした配列と引き算するだけで、「最後→最初」も含めた全部のコマ送りの差が一度に出ます。
記事の見本4本に使うと、こうなりました。
| 見本 | 平均の差(つなぎ目 / ふだん) | 大きく変わる画素(つなぎ目 / ふだん) | 判定 |
|---|---|---|---|
| 泡・直す前 | 17.48 / 8.19(2.1倍) | 9,790 / 5,118 | NG |
| 泡・直したあと | 9.96 / 9.32(1.1倍) | 4,966 / 5,680 | OK |
| 惑星・直す前 | 3.04 / 0.25(12.1倍) | 788 / 0 | NG |
| 惑星・直したあと | 0.72 / 0.65(1.1倍) | 0 / 0 | OK |
「NG」の線(ふだんの1.5倍+50画素)は、この見本で決めた目安です。映像によって変わるので、NGが出たら差の画像を見て確かめるのがおすすめです。
テストにAIを使ってみてよかったこと
今回いちばん「いい感じ」だと思ったのは、作った自分とは別の目として、AIにテストを任せたことです。
作った自分は「継ぎ目なしでつながるように作った」と思っているので、つなぎ目を疑いにくい。テスト係は、その前提を持っていない
「なんとなく変」ではなく、「ふだんの約8倍」「差は惑星の上」と数字と場所で返ってくるので、そのまま直しに取りかかれる
原因になっていそうなコードの行と、直し方の案まで書いてあった(泡の mod はそのまま採用)
夜の街のように「数字は大きいけど直さなくてよい」も理由つきで書いてくれるので、全部を直しに行かずにすむ
テスト係への頼み方は、「ループがなめらかにつながっているか、数字で確かめて。差がどこに出ているかの画像もつけて」くらいでした。自分の目で何回見ても分からなかったことが、数字ではっきり出るのは気持ちよかったです。
まとめ
ループ動画のつなぎ目は「最後のコマ → 最初のコマ」。t=32 と t=0 を比べても、同じ絵なので意味がない
となりのコマどうしの差を全部はかって、つなぎ目だけ飛び出ていないかを見る。「ふだんの何倍か」と「大きく変わる画素の数」で判断する
回る模様は、ちょうど整数回まわし、角度は cos ・ sin にしてからノイズに入れる
流れる粒は、マスの番号を「1ループで流れるマスの数」でわった余りにする
ループする映像を作っていて、なんとなくつなぎ目が気になったら、上のコードで一度はかってみてください。


