新しい iPhone が出るたびに、壁紙アプリの解像度の分岐を書き足しています。画面の pt が増えたぶんだけ配布する画像の実ピクセルが変わるので、素材の側をどれだけ立派に作っても、最後は受け取り側のバケットに合わせて切り直すことになります。Android 側では密度分割で低密度のリソースが落ちてしまい、drawable-nodpi/ に置き直して解決したこともありました。
配布物の解像度を決めているのは素材ではなく、受け取る側の条件でした。個人開発でこの順序を取り違えると、作り直しがそのまま時間の損失になります。
gemini-omni-1.1-flash が一般提供に入り、動画の出力解像度を 360p から 4k まで選べるようになりました。ただしドキュメントには、1080p と 4K がアップスケールによる出力だと明記されています。ここで「とりあえず 4K にしておく」と決めてしまうと、壁紙で踏んだのと同じ順序の取り違えが起きます。
resolution は response_format の中にあります
最初につまずきやすいのが、パラメータの置き場所です。解像度は generation_config.video_config ではなく、response_format の中に入れます。video_config に入るのは task(text_to_video / image_to_video / reference_to_video / edit / extend)のほうです。
置き場所を間違えても、リクエストは通ります。そして既定の 720p が返ってきます。エラーで気づける類の間違いではないため、最初の 1 本は必ず ffprobe で寸法を確認してください。
import base64
from google import genai
client = genai.Client()
interaction = client.interactions.create(
model="gemini-omni-1.1-flash",
input="A drone shot of a mountain landscape at sunrise.",
response_format={
"type": "video",
"aspect_ratio": "9:16", # 既定は 16:9。縦動画なら明示が要ります
"resolution": "1080p", # ここ。video_config ではありません
},
)
with open("hires.mp4", "wb") as f:
f.write(base64.b64decode(interaction.output_video.data))
選べる値と、その意味は次のとおりです。
| 値 | 出力 | 生成の実体 |
360p | 360p | ネイティブ |
720p | 720p(既定) | ネイティブ |
1080p | 1080p | アップスケール |
4k | 4K | アップスケール |
つまり、この API がネイティブに作れる上限は 720p です。1080p と 4K は、その 720p を引き伸ばした結果に名前を付けたものだと読めます。詳細は Gemini Omni Flash の公式ドキュメント に一覧があります。
4K を選んで増えるもの、増えないもの
アップスケールという言葉は知っていても、実際に何がどう変わるのかは手を動かさないと掴めません。API を叩く前に、手元の合成素材で性質だけを確かめました。
用意したのは 3 本です。いずれも 2 秒・24fps・CRF 18 で、名目の寸法はすべて 3840x2160 に揃えてあります。
- 細かい縞とエッジを持つ絵を、はじめから 3840x2160 で作ったもの(ネイティブ 4K 相当)
- 同じ絵を 1280x720 で作り、Lanczos で 3840x2160 に引き伸ばしたもの(アップスケール 4K 相当)
- なだらかなグラデーションだけの絵を、はじめから 3840x2160 で作ったもの(細部を持たないネイティブ 4K)
ffprobe で寸法を読むと、3 本とも 3840,2160 です。ファイルのメタデータだけでは区別が付きません。一方で、同じエンコード設定なのにビットレートは大きく割れました。
| 素材 | 名目寸法 | ビットレート | ファイルサイズ(2秒) |
| ネイティブ 4K(細密) | 3840x2160 | 17.19 Mbps | 4,299,566 B |
| アップスケール 4K | 3840x2160 | 7.25 Mbps | 1,812,869 B |
| ネイティブ 4K(平坦) | 3840x2160 | 0.59 Mbps | 147,756 B |
同じ寸法・同じ CRF で、アップスケール由来はネイティブの 42% ほどのビットレートに収まりました。エンコーダは「そこに無い細部」には符号を割かないので、当然といえば当然の結果です。ただ、この当然が実務では効いてきます。4K を指定して増えるのは寸法であって、情報量ではありません。
名目解像度に見合う細部が入っているかを手元で確かめる
寸法が同じで区別が付かないなら、周波数の側から見ます。720p の絵を引き伸ばした映像は、720p のナイキスト周波数より上の帯域にほとんどエネルギーを持ちません。そこを測れば、名目の寸法に見合う細部が入っているかを判定できます。
"""名目解像度に見合う細部が入っているかを、同じ素材の 720p 版を基準に相対判定する。
使い方: python3 native_check.py candidate.mp4 reference_720p.mp4
"""
import subprocess
import sys
import numpy as np
def grab_frame(path, at="00:00:01"):
"""指定時刻の 1 フレームを grayscale の生データで取り出します。"""
wh = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0", path],
capture_output=True, text=True, check=True).stdout.strip().split(",")
w, h = int(wh[0]), int(wh[1])
raw = subprocess.run(
["ffmpeg", "-v", "error", "-ss", at, "-i", path, "-frames:v", "1",
"-pix_fmt", "gray", "-f", "rawvideo", "-"],
capture_output=True, check=True).stdout
return np.frombuffer(raw, np.uint8).reshape(h, w).astype(np.float64), w, h
def detail_density(frame, w, h):
"""画素あたりの高周波エネルギー密度。寸法が違う動画同士でも比べられるよう、
総エネルギーで正規化せず面積で割ります。"""
win = np.hanning(h)[:, None] * np.hanning(w)[None, :] # 端の不連続を殺します
spec = np.abs(np.fft.fftshift(np.fft.fft2(frame * win))) ** 2
fy = (np.arange(h) - h // 2) / h
fx = (np.arange(w) - w // 2) / w
r = np.sqrt(fy[:, None] ** 2 + fx[None, :] ** 2)
band = spec[(r > 0.25) & (r <= 0.5)].sum() # 引き伸ばすと空になる帯域
return float(band / (w * h))
if __name__ == "__main__":
cf, cw, ch = grab_frame(sys.argv[1])
rf, rw, rh = grab_frame(sys.argv[2])
cd = detail_density(cf, cw, ch)
rd = detail_density(rf, rw, rh)
gain = cd / rd if rd > 0 else float("inf")
label = "アップスケール相当" if gain < 3 else "寸法に見合う細部あり"
print(f"{sys.argv[1]}: 名目 {cw}x{ch} / 細部密度比 {gain:.2f}x -> {label}")
3 本に当てた結果です。基準には、同じ絵の 720p 版を使いました。
| 素材 | 細部密度比(720p 版との比) | 判定 |
| ネイティブ 4K(細密) | 44.72x | 寸法に見合う細部あり |
| アップスケール 4K | 0.46x | アップスケール相当 |
| ネイティブ 4K(平坦) | 0.01x | アップスケール相当 |
細密なネイティブとアップスケールのあいだには 97 倍近い開きが出ました。この差なら、目視で迷う前に数字で切り分けられます。
比べるものを 1 つ固定してから測る、という形は壁紙のカテゴリ分類でも取りました。複数のモデルを並行させたときの整理は Gemini 3 Pro と 2.5 Pro を壁紙カテゴリ分類に並行投入した実装メモ に残してあります。
絶対しきい値は使えませんでした
最初は、基準動画を用意しない形で書いていました。全エネルギーに対する高帯域の比率を出して、しきい値 0.01 を下回ったらアップスケールと判定する、という素朴な実装です。結果は次のようになりました。
| 素材 | 高帯域比 | しきい値 0.01 での判定 |
| ネイティブ 4K(細密) | 0.00607 | アップスケールと誤判定 |
| アップスケール 4K | 0.00011 | アップスケール |
| ネイティブ 4K(平坦) | 0.00000 | アップスケールと誤判定 |
3 本すべてがアップスケール判定になりました。とくに 3 本目は、正真正銘のネイティブ 4K でありながら値が 0 に張り付いています。絵柄が平坦なら、ネイティブでも高い帯域には何も入らないからです。
ここで方針を変えました。この測り方が答えられるのは「ネイティブかどうか」ではなく、「その寸法に見合う細部が実際に入っているか」です。そして実務で知りたいのは後者のほうでした。だから基準線を外から与える相対判定に切り替えています。720p 版を 1 本だけ余分に生成しておけば、以後の判定はその 1 本を使い回せます。
私はこの手の判定に絶対しきい値を置きたくなる癖があります。設定ファイルに数字を 1 つ書けば済むので楽なのですが、素材で動く指標にしきい値を置くと、静かに誤判定を続ける仕組みができあがります。観測の考え方については AI Studio の開発者ログを本番の正本にしない設計 にも書きました。
二重に引き上げると劣化する、という予想は外れました
もうひとつ確かめたのが、引き上げを 2 回に分けた場合の劣化です。配布先が 1080p のとき、次の 2 経路が考えられます。
- API に
4k を指定して受け取り、自分で 1080p へ縮小する(拡大と縮小で 2 回)
720p のまま受け取り、配布直前に 1 度だけ 1080p へ拡大する(1 回)
拡大と縮小を重ねれば眠い絵になるはずだと予想していました。実際に両経路を作って比べたところ、細部密度比は経路 2 が経路 1 の 1.02 倍、2 経路のフレーム間 PSNR は 40.79 dB でした。目で見て違いが分かる差ではありません。
つまり、引き上げ地点の選択は画質の問題ではありませんでした。ここは正直に書いておきたいところで、私の事前の予想は外れています。では何が違うのかというと、受け取り量と待ち時間です。
同じ素材・同じ CRF で比べると、720p は 402,983 B、名目 4K は 1,812,869 B でした。4.50 倍です。Interactions API は生成した動画を base64 で返してくるため、この差はそのままレスポンスの大きさに乗ります。名目 4K で約 2.3 MiB、720p なら約 0.5 MiB という計算になります。2 秒の動画でこの差ですから、本数を並べたときの効き方は無視できません。
では何で決めるのか
画質で決まらないなら、判断材料は次の 3 つに絞られます。
- 配布先が受け付ける寸法: ストアのプレビュー動画のように、寸法そのものが受理条件になっている場合は選択の余地がありません
- 受け取り量: 素材を何本も回す段階では、720p で受けて後段で 1 度だけ揃えるほうが軽く済みます
- 後工程の有無: 自分で ffmpeg を通す工程が既にあるなら、そこに引き上げを寄せられます
これを設定として書き下すと、判断が毎回ぶれなくなります。
NATIVE_CEILING_H = 720 # この API がネイティブに作れる上限
def choose_resolution(target_h: int, has_postprocess: bool) -> tuple[str, str]:
"""(API に渡す resolution, 引き上げ地点) を返します。
target_h は配布先が要求する縦の実ピクセル数です。
"""
if target_h <= 360:
return "360p", "none" # 縮小だけで足ります
if target_h <= NATIVE_CEILING_H:
return "720p", "none" # ネイティブで足りる領域
if has_postprocess:
# どうせ ffmpeg を通すなら、受け取りは軽いほうを選びます
return "720p", "local"
# 後工程を持たない配布経路では、API 側に寸法を揃えてもらいます
return ("1080p" if target_h <= 1080 else "4k"), "api"
for target_h, post in [(720, False), (1080, True), (1080, False), (2160, False)]:
res, point = choose_resolution(target_h, post)
print(f"target={target_h}p postprocess={post} -> resolution={res} upscale_at={point}")
実行するとこうなります。
target=720p postprocess=False -> resolution=720p upscale_at=none
target=1080p postprocess=True -> resolution=720p upscale_at=local
target=1080p postprocess=False -> resolution=1080p upscale_at=api
target=2160p postprocess=False -> resolution=4k upscale_at=api
肝心なのは upscale_at が必ず 1 箇所に決まることです。ここが曖昧なまま複数のスクリプトが動くと、API 側で 4K に引き上げたものをさらに別の工程が拡大する、という経路ができてしまいます。画質の劣化は測ったとおり軽微でしたが、転送量と生成待ちは素直に積み上がります。
配布先の要求寸法は、App Store も Google Play も更新されます。この値をコードに直書きせず、配布先ごとの設定として外に出しておくことをお勧めします。ストアの仕様が変わったときに直す場所が 1 箇所で済みます。壁紙アプリで端末ごとの解像度を扱ったときの整理は Gemini 3.2 Flash の Image Output で壁紙のバリエーションを生成した実装メモ にまとめてあります。
extend を挟むときは解像度を先に固定しておく
Omni Flash は動画の延長にも対応しました。プロンプトで「この動画を続けてください」と指示すると、3 〜 10 秒の継続部分が生成されます。既に生成した動画は previous_interaction_id で参照でき、手元の動画は Files API 経由で渡せます。
継ぎ足しを前提にするなら、解像度は最初のカットを作る段階で決めておくのが安全です。途中で resolution を変えると、後段で寸法を揃える工程が余分に増えます。しかも、揃えるために縮小した部分と、そのままの部分とで細部の量が変わるため、つなぎ目で質感が変わって見える余地が残ります。
私が取っている手順は次のとおりです。
- 構図とタイミングを詰める段階は
360p で回します。コストと待ち時間が最も軽くなります
- 尺の構成が固まったら
720p に上げて、延長も同じ設定で通します
- 全カットが揃ってから、配布先ごとの寸法へ 1 度だけ引き上げます
延長の制約はドキュメント側でも更新が入っています。実装に入る前に一度は目を通しておくと、後戻りが減ります。
delivery を uri にしても GET は base64 が返ります
受け取り経路にも、知らないと踏む挙動があります。delivery: "uri" を指定して作成した場合でも、GET /v1beta/interactions/{id} は動画を inline の base64 として data フィールドに返します。uri フィールドの存在が保証されるのは、作成時の最初のレスポンスか SSE ストリームのときだけです。
生成を非同期にして、あとから ID で取りに行く設計にしていると、ここで想定外のペイロードを受け取ることになります。4K を選んでいれば、そのぶん重いものが返ってきます。
対処は単純で、URI が必要なら作成時のレスポンス(あるいは SSE イベント)でその場に保存しておくことです。後から取り直せる前提でコードを書かないほうが安全でした。
interaction = client.interactions.create(
model="gemini-omni-1.1-flash",
input="A drone shot of a mountain landscape at sunrise.",
response_format={"type": "video", "resolution": "720p"},
)
# uri が必要なら、この時点で確保します(後から GET しても base64 が返ります)
video = interaction.output_video
uri = getattr(video, "uri", None)
if uri:
save_uri_to_manifest(interaction.id, uri)
else:
save_bytes(interaction.id, base64.b64decode(video.data))
移行の期限だけは先に押さえておく
gemini-omni-flash-preview は 2026 年 9 月 30 日で停止します。6 月 30 日にパブリックプレビューへ出たエンドポイントなので、3 か月ほどで役目を終えることになります。本番のコードがプレビュー版を参照しているなら、猶予はひと月です。
差し替え自体はモデル名の置換で済む場面が多いはずです。ただし、既定解像度が 720p として明示されたことで、プレビュー時代の暗黙の挙動に寄りかかっていた箇所は結果が変わる可能性があります。置換したら、寸法を ffprobe で 1 回確認してください。停止日の一覧は Gemini API の廃止予定ページ で確認できます。
エンドポイントの停止で手元のコードが黙って動かなくなる経験は、画像生成モデルの停止でも一度しています。期限が公開されているうちに手を入れておくほうが、結果的に安く済みました。
次の一歩
まず 720p で 1 本生成して、それを基準動画として保存してください。細部密度の判定も、引き上げ地点の決定も、この 1 本があるかどうかで手間が変わります。
解像度の指定は、画質を上げる操作ではなく、配布先の受け入れ条件に寸法を合わせる操作でした。この順序さえ間違えなければ、4k を選ぶ場面も選ばない場面も、迷わずに決められます。
壁紙の配布で同じことを何年も繰り返してきたはずなのに、新しい API を前にするとまた素材の側から考え始めてしまいました。読んでくださってありがとうございました。