9月の初めに、壁紙アプリのストア用の短い紹介動画を作り直していました。数秒のカットを継ぎ足していって、最後はアプリのアイコンが浮かび上がるところで終わらせるつもりでした。
延長を三回重ねたところで手が止まりました。継ぎ目はどこも滑らかで、通して観るかぎり破綻は見当たりません。それなのに、最後のカットの色味が、最初のカットとは違う空気になっていたのです。
用意してあったアイコンの背景は、最初のカットに合わせて作ってありました。ずれていたのは動画のほうで、直すなら継ぎ足した分をまとめてやり直すしかありません。
そのとき気づいたのは、シーン延長とキーフレーム補間が「同じ長さを作るための二つの手段」ではない、ということでした。二つは伸び方ではなく壊れ方が違います。そして壊れ方が違う以上、選ぶのは行き詰まったときではなく、最初の1本を生成する前になります。
累計40秒の上限は、走り出したあとには動かせません
Omni 1.1 のシーン延長は、既存の動画の続きを生成します。刻みは10秒単位で、累計の長さは40秒までと決まっています。これは Google 公式の Gemini Omni 1.1 Flash の告知に明記されている数字です。
厄介なのは、この上限が「途中で気づいて別の方法に切り替える」という逃げ道を塞いでいる点だと思います。延長で積み上げた30秒を、あとからキーフレーム補間の連鎖に組み替えることはできません。両端を固定する作り方は、最初の生成の時点で両端の絵を持っている必要があるからです。
つまり40秒の壁は、尺の上限であると同時に、判断の締切でもあるのです。
延長が読んでいるのは、直前の10秒です
Omni 1.1 で変わったのは参照する範囲です。以前のモデルが最後の1秒しか見ていなかったのに対して、直前の10秒までを文脈として読むようになりました。継ぎ目の自然さは、この差でかなり良くなっています。
ここに落とし穴がありました。継ぎ目が良くなるほど、ずれは継ぎ目に現れなくなります。
一つひとつの接続は前の10秒に忠実で、隣り合うカットを見比べるかぎり違和感はありません。ところが忠実さは「直前」に対する忠実さであって、「起点」に対する忠実さではないのです。一歩ずつ正しく進んだ結果、出発点からはずいぶん離れた場所に着いている——私が三回目の延長で踏んだのは、まさにこれでした。
呼び出し自体は素直な形をしています。公式が示している延長の書き方は次のとおりです。
from google import genai
client = genai.Client()
interaction = client.interactions.create(
model="gemini-omni-1.1-flash",
previous_interaction_id=previous_video_interaction.id,
input=[
{"type": "text", "text": "Continue the scene."}
],
response_format={
"resolution": "360p",
},
)
previous_interaction_id で前の生成を指すだけなので、書くのは1行増える程度です。手軽さと、あとから引き返せないことが同居しているのが、この機能の性格だと感じています。
補間は両端を止める代わりに、真ん中を明け渡します
キーフレーム補間は、始まりのフレームと終わりのフレームを指定して、その間を生成させる作り方です。カメラのオービット、寄りと引きの切り替え、ループするクリップに向いていると公式は説明しています。
延長との違いは、責任の置き場所です。延長では、次に何が起きるかをモデルが決めます。補間では、着地点は自分が決めて、そこへどう辿り着くかをモデルに委ねます。
その代わり、真ん中はこちらから触れません。動きが意図と違っていたときに直せるのは、両端の絵かプロンプトのどちらかであって、途中の1秒だけを差し替えることはできないのです。
終わりの絵が決まっている動画は、伸ばすのではなく挟みます。 アイコンで終わる告知動画も、同じ構図に戻ってくるループも、着地が先に決まっている以上、延長で近づけようとするのは遠回りでした。
どちらを選ぶかは、着地の絵が決まっているかで分かれます
判断の材料を一枚に並べておきます。
| 観点 | シーン延長 | キーフレーム補間 |
| 尺の上限 | 累計40秒(10秒刻み) | セグメントを繋げば上限なし |
| 継ぎ目 | 滑らかだが、起点からのずれが積もる | 固定した絵で一致するため積もらない |
| 着地の絵 | 生成されるまで分からない | 先に用意する必要がある |
| やり直しの単位 | その先の連鎖すべて | 該当セグメント1本 |
| 向く場面 | 物語を分岐させたい/終わりを探している | ロゴ・UI・同じ構図へ戻す |
| 下書きの効き方 | プロンプト選びに効く | キーフレーム作りに効く |
「やり直しの単位」の行が、私にとっていちばん重い違いでした。延長は失敗のコストが後ろに伝播します。補間は失敗が1本に閉じます。
尺の割り付けを、生成の前に計算しておきます
判断を頭の中だけに置いておくと、締切前には必ず「とりあえず1本生成してから考える」に流れます。私自身がそうだったので、いまは先に計算だけ済ませるようにしました。
base_seconds は自分の手元の1回分の出力尺です。環境や設定で変わるところなので、実測した値を渡す前提にしています。
import math
EXTEND_INCREMENT = 10.0 # 公式: 10秒刻み
EXTEND_CAP = 40.0 # 公式: 累計40秒まで
def plan_shot(target_seconds, base_seconds, ending_frame_fixed):
"""尺の作り方を、生成を始める前に決める。
base_seconds は自分の手元の1回分の出力尺(実測して渡す)。"""
if target_seconds <= base_seconds:
return {"mode": "single", "generations": 1, "seams": 0,
"reason": "1回分に収まります"}
if ending_frame_fixed:
segments = math.ceil(target_seconds / base_seconds)
return {"mode": "interpolate", "generations": segments,
"seams": segments - 1, "pinned_seams": segments - 1,
"reason": "着地の絵が決まっているため両端を固定します"}
if target_seconds <= EXTEND_CAP:
need = target_seconds - base_seconds
steps = math.ceil(need / EXTEND_INCREMENT)
return {"mode": "extend", "generations": 1 + steps, "seams": steps,
"pinned_seams": 0,
"reason": f"累計 {EXTEND_CAP:.0f} 秒の枠内に収まります"}
segments = math.ceil(target_seconds / EXTEND_CAP)
return {"mode": "interpolate_chain", "generations": segments,
"seams": segments - 1, "pinned_seams": segments - 1,
"reason": f"{target_seconds:.0f} 秒は延長の上限 {EXTEND_CAP:.0f} 秒を超えます"}
手元で五つの条件を通すと、こう出ます。
target= 8s base=8s fixed_end=False -> single gen=1 seams=0
target= 24s base=8s fixed_end=False -> extend gen=3 seams=2
target= 24s base=8s fixed_end=True -> interpolate gen=3 seams=2
target= 40s base=8s fixed_end=False -> extend gen=5 seams=4
target= 55s base=8s fixed_end=False -> interpolate_chain gen=2 seams=1
同じ24秒でも、着地の絵が決まっているかどうかで作り方が入れ替わります。継ぎ目の本数は同じ2本ですが、片方は積もる継ぎ目で、もう片方は固定された継ぎ目です。数だけを見比べても意味がないところが、この関数を書いていていちばん腑に落ちた点でした。
継ぎ目のずれは、目ではなく数字で追います
私が最初の失敗で見落としたのは、目視では追えない種類のずれでした。並べれば分かるのに、順に観ていくと分からないのです。
そこで、色の分布の距離だけを見る小さな検査を挟むようにしました。動きや構図までは見ませんが、色が流れていく事故はこれで止まります。
import numpy as np
from PIL import Image
def _hist(img, bins=32):
a = np.asarray(img.convert("RGB").resize((160, 90)), dtype=np.uint8)
h = np.concatenate([
np.histogram(a[:, :, c], bins=bins, range=(0, 256))[0] for c in range(3)
]).astype(np.float64)
return h / h.sum()
def tone_distance(img_a, img_b):
"""0.0(同一)〜1.0(完全に別)。色ヒストグラムの L1 距離を正規化。"""
return float(np.abs(_hist(img_a) - _hist(img_b)).sum() / 2.0)
def audit_chain(segment_frames, seam_limit=0.06, anchor_limit=0.15):
"""segment_frames: [(先頭フレーム, 末尾フレーム), ...] を生成順に。"""
anchor = segment_frames[0][0]
rows, ng = [], []
for i, (head, tail) in enumerate(segment_frames):
seam = tone_distance(segment_frames[i - 1][1], head) if i else 0.0
drift = tone_distance(anchor, tail)
rows.append((i + 1, seam, drift))
if seam > seam_limit:
ng.append(f"seg{i+1}: 継ぎ目 {seam:.3f} > {seam_limit}")
if drift > anchor_limit:
ng.append(f"seg{i+1}: 起点からのずれ {drift:.3f} > {anchor_limit}")
return rows, ng
しきい値を本番の素材で決める前に、意図的に色を寄せた連鎖でチェッカー自身を確かめておきます。継ぎ目をぴったり繋いだ5本の連鎖に通すと、次のようになりました。
seg seam drift_from_anchor
1 0.000 0.040
2 0.000 0.071
3 0.000 0.101
4 0.000 0.131
5 0.000 0.160
NG: ['seg5: 起点からのずれ 0.160 > 0.15']
継ぎ目はすべて 0.000 です。それでも起点からの距離は一段ずつ増えていって、5本目で線を越えました。私が観ながら気づけなかったものが、そのまま表に出ています。
作り直したセグメントを差し込んで継ぎ目が飛んだ場合も、別の行で引っかかります。
[継ぎ目が飛んだ場合]
seg seam drift_from_anchor
1 0.000 0.040
2 0.081 0.140
NG: ['seg2: 継ぎ目 0.081 > 0.06']
しきい値の 0.06 と 0.15 は、私の素材で「言われれば分かる」あたりに置いた数字です。そのまま使うのではなく、手持ちの2本で一度測ってから決めていただければと思います。
360p の下書きで決められること、決められないこと
Omni 1.1 の 360p は、720p と比べて最大60%速く、費用は3分の1と説明されています。試行の回数を増やせる幅としては、かなり大きいところです。
ただ、下書きが効く場所は作り方によって違います。延長では、下書きで決めたいのはプロンプトの言い回しなので、360p のまま何本も回して構いません。補間では、下書きで決めたいのは両端に固定する絵そのものです。そして 360p のフレームは、720p の本番にそのまま固定できません。
ここを混ぜると、下書きのつもりで作った絵が本番のキーフレームとして紛れ込みます。個人開発では素材の置き場が一つのフォルダになりがちで、私は実際に一度取り違えました。渡す前に検問を置いています。
from PIL import Image
RESOLUTION_HEIGHT = {"360p": 360, "720p": 720, "1080p": 1080, "4k": 2160}
def check_keyframes(first_path, last_path, target="720p"):
"""両端に固定する2枚を、生成へ渡す前に検問する。"""
need_h = RESOLUTION_HEIGHT[target]
problems = []
sizes = []
for label, path in (("first", first_path), ("last", last_path)):
with Image.open(path) as im:
w, h = im.size
sizes.append((w, h))
if h < need_h:
problems.append(
f"{label}: 高さ {h}px は {target}({need_h}px)に足りません")
(w1, h1), (w2, h2) = sizes
if abs(w1 / h1 - w2 / h2) > 0.01:
problems.append(f"first {w1}x{h1} と last {w2}x{h2} でアスペクト比が違います")
return problems
三つの組み合わせで確かめた結果です。
720p + 720p : 問題なし
720p + 360p下書き: ['last: 高さ 360px は 720p(720px)に足りません']
720p + 正方形 : ['first 1280x720 と last 1080x1080 でアスペクト比が違います']
アスペクト比のほうは、縦位置の告知素材と横位置のストア素材を並行して作っているときに効きます。1080×1080 を最後に据えたまま横位置の生成へ回すと、出てくるものは一応それらしく見えるので、目視では気づきにくいのです。
最終的な解像度をどこで上げるかは、また別の判断になります。API 側で 4K を受け取るか配布の直前に自分で上げるかについては、Omni Flash の 4K をどこで受け取るかの検討に書きました。
本番運用で先に潰しておいた三つ
一度やってみて、次からは最初に潰すようにした点を挙げておきます。どれも回避そのものは数分で済むのに、後から気づくと連鎖ごと作り直しになるものです。
- 連鎖の途中で解像度を変えないこと。 下書きと本番を1本の連鎖に混ぜると、どこから本番だったのかが後から辿れなくなります。360p で詰めたら、本番は最初の1本から作り直すのが結局は速い道でした。
- 生成した interaction の id を成果物と並べて残すこと。 延長はどこから作り直すかを id でしか指せません。ファイル名だけで管理していたときは、三つ前のテイクへ戻る手段がありませんでした。
- 累計の数え方を、自分の連鎖で確かめてから見積もること。 捨てたテイクの扱いは自分の手元で一度確認するようにしています。この場合は、見積もりに余裕を持たせるほうが安全です。
いま私が踏んでいる順序
失敗のあとで、順序をこう固定しました。
- 完成尺と、終わりの絵が決まっているかどうかを先に紙に書きます。
plan_shot に通して、延長か補間かと生成回数を確定させます。
- 補間なら、両端のキーフレームを本番解像度で先に作り、
check_keyframes を通します。
- 延長なら、360p でプロンプトだけを詰めてから本番解像度に上げます。
- 生成し終えたら、
audit_chain で継ぎ目と起点からのずれを両方見ます。
- しきい値を越えていたら、越えた位置より前へは戻らずに、そこから補間へ切り替えます。
6番だけは自分への戒めとして書いています。延長でずれが出たとき、いちばんやりたくなるのは「もう一度きれいに繋ぎ直す」なのですが、積もるものは繋ぎ直しても積もるのです。
なお gemini-omni-flash-preview は9月30日で廃止されます。preview のまま延長機能のない構成で回している場合は、移行そのものが先です。期限の棚卸しは9月30日の廃止に向けた期限の整理にまとめてあります。
短い動画ほど、決めるのは最初でした
今回いちばん腑に落ちたのは、動画の長さと判断の重さが比例しないということでした。30秒の告知素材でも、作り方を選ぶタイミングを逃すと、やり直しは全部になります。
次に短い動画を作られるときは、生成を始める前に一つだけ決めておいてください。最後のフレームが、もう手元にあるかどうかです。そこが決まっていれば、延長と補間のどちらを選ぶかは自然に決まります。
私自身、まだ両端を用意する手間を惜しんで延長に流れることがあります。それでも、この順序だけは崩さないようにしています。最後までお読みくださり、ありがとうございました。