ループ動画の「つなぎ目」で絵が飛ぶのを、AIのテスト係が数字で見つけてくれた

 AI 

 Claude 

 GLSL 

作成日:2026/11/14

はじめに

配信の待機画面っぽい、ずっとくり返して流せる背景動画を作って遊んでいます(前回のBGMの記事と同じ遊びです)。

宇宙で惑星が回っていたり、深海で泡が上がっていったりする映像を、GLSL(シェーダー)で描いて、32秒の動画に書き出しています。

32秒たったら最初に戻るので、「最後のコマ」と「最初のコマ」がなめらかにつながっていないと、くり返すたびに絵がパッと飛びます。

できあがった動画のテストには、AI(Claude)を使ってみました。「テスト係」として、動画を渡して確かめてもらうやり方です。

自分では「ループは継ぎ目なしでつながっている」つもりで、何度か見て「いい感じ」と思っていました。

ところが、テスト係から返ってきたのはこんな報告でした。

🧪
テスト係(Claude)宇宙の背景のループのつなぎ目で、惑星の模様がパッと切り替わります。32秒目→0秒目のコマの差は、ふだんのコマどうしの約8倍(2.5 対 0.3)。差のほとんどが惑星の上にあります。
🧪
テスト係(Claude)深海の背景も、つなぎ目の差がふだんの約3倍(1.7 対 0.57)。差は泡の位置に集まっていて、泡が消えたり別の場所に出たりしています。
🙋
自分……全然気づかなかった。

言われてからつなぎ目をじっと見ると、たしかに飛んでいます。

この記事では、テスト係がやっていた「つなぎ目を数字で確かめる方法」と、見つかった2つのズレの原因・直し方を、記事用に作った小さな見本で再現しながら紹介します。

記事用に作った見本(4秒ループを3回)。左が直す前、右が直したあと。「つなぎ目」が出た瞬間に左だけ絵が飛びます
約8倍宇宙の背景のつなぎ目の差
約3倍深海の背景のつなぎ目の差
4,364→155直したあとの「大きく変わる画素」(宇宙)
25行確かめるPython
💡
この記事で分かること ・ループ動画の「つなぎ目」を、目ではなく数字で確かめる方法(コードつき) ・「最後の時刻と最初の時刻を比べる」が、うまくいかない理由 ・回る模様と、流れる粒(泡)がループでずれる原因と、その直し方

ループの「つなぎ目」とは

32秒・30fpsの動画は 960コマです。再生すると 0, 1, 2, …, 959 と進んで、959 の次にまた 0 に戻ります。

つまり、ふつうのコマ送り(たとえば 100→101)と同じように、「959→0」もなめらかでないといけません。この「959→0」がつなぎ目です。

0
1
2
…
479
480
…
958
959
↺ 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コマ)で、実際にはかったのがこのグラフです。

泡の背景と回る惑星で、となりのコマとの差を1ループ分並べたグラフ。直す前は右端のつなぎ目だけが大きく飛び出ていて、泡で3倍台、惑星で15倍。直したあとは平らなまま
赤が直す前、水色が直したあと。右端(119→0)がつなぎ目です(図はタップで大きく開きます)

ほとんどのコマ送りは平らで、つなぎ目だけがピンと立っています。こうなっていれば、目で見なくても「ここで飛んでいる」と言えます。

差がどこに出ているかを画像にすると、原因の当たりもつけられます。テスト係の報告に「差のほとんどが惑星の上にある」と書いてあったのは、これを見ていたからです。

「最後の時刻と最初の時刻を比べる」ではダメな理由

最初、自分は「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つ目の原因は、惑星の回し方でした。テスト係の報告を、見本で再現したのがこちらです。

回る惑星の最後のコマと最初のコマ、その差の画像。直す前は惑星の模様全体が赤く、まるごと入れかわっている。直したあとは差がほとんどない
右の列が2枚の差。直す前は惑星全体が赤くなっています

直す前のコードは、だいたいこうなっていました。

// 直す前
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=8)
ループ開始番号 3 → 泡A
ループ最後番号 −5 → 泡X
同じ場所なのに番号が8ずれて、乱数が別物に
直したあと(番号を8でわった余り)
ループ開始3 → 余り3 → 泡A
ループ最後−5 → 余り3 → 泡A
1ループ後に、同じ泡が同じ場所に戻ってくる

直し方の案も、テスト係の報告に書いてありました。番号を 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,118NG
泡・直したあと9.96 / 9.32(1.1倍)4,966 / 5,680OK
惑星・直す前3.04 / 0.25(12.1倍)788 / 0NG
惑星・直したあと0.72 / 0.65(1.1倍)0 / 0OK

「NG」の線(ふだんの1.5倍+50画素)は、この見本で決めた目安です。映像によって変わるので、NGが出たら差の画像を見て確かめるのがおすすめです。

テストにAIを使ってみてよかったこと

今回いちばん「いい感じ」だと思ったのは、作った自分とは別の目として、AIにテストを任せたことです。

作った自分は「継ぎ目なしでつながるように作った」と思っているので、つなぎ目を疑いにくい。テスト係は、その前提を持っていない

「なんとなく変」ではなく、「ふだんの約8倍」「差は惑星の上」と数字と場所で返ってくるので、そのまま直しに取りかかれる

原因になっていそうなコードの行と、直し方の案まで書いてあった(泡の  mod  はそのまま採用)

夜の街のように「数字は大きいけど直さなくてよい」も理由つきで書いてくれるので、全部を直しに行かずにすむ

テスト係への頼み方は、「ループがなめらかにつながっているか、数字で確かめて。差がどこに出ているかの画像もつけて」くらいでした。自分の目で何回見ても分からなかったことが、数字ではっきり出るのは気持ちよかったです。

まとめ

ループ動画のつなぎ目は「最後のコマ → 最初のコマ」。t=32 と t=0 を比べても、同じ絵なので意味がない

となりのコマどうしの差を全部はかって、つなぎ目だけ飛び出ていないかを見る。「ふだんの何倍か」と「大きく変わる画素の数」で判断する

回る模様は、ちょうど整数回まわし、角度は  cos ・ sin  にしてからノイズに入れる

流れる粒は、マスの番号を「1ループで流れるマスの数」でわった余りにする

ループする映像を作っていて、なんとなくつなぎ目が気になったら、上のコードで一度はかってみてください。