Veo 3.1 と Gemini 2.5 Flash Image(Nano Banana)を用いて、フレームごとの説明テキストから絵コンテを作り、長尺のアニメーションまで一気に生成できる Web アプリをCodexが開発しました。
という記事をChatGPTに書いてもらいました。
何をやりたかったか
Veo 3.1 単体でも、前のフレームを次の生成に渡し続ければ長尺のアニメーションは生成可能です。
しかしこの方法だとキャラの顔つきが少しずつズレたり、背景や色が乱れがちです。
このWebアプリでは、
- ユーザーがフレームごとの説明(+任意の「どんな動きか」メモ)を Web UI から入力し、
- それをそのまま Gemini 2.5 Flash Image に食わせて絵コンテを作り、既存フレームを参照しながらスタイルとキャラを維持しつつ、
- 連続するフレーム間を Veo 3.1 の補間でつなぎ、最後に ffmpeg で 1 本の MP4 にまとめる
という構成にすることで、長尺でも破綻しづらいアニメーションの生成を目指しました。
できあがったもの
https://github.com/rokuroku-066/gen_video
この Web アプリでは、ブラウザ上で
- フレームごとの説明テキストを追加/挿入する
- Gemini 2.5 Flash Image で絵コンテを 1 フレームずつ生成・再生成する
- すべてのフレームの画像が揃ったら、Veo 3.1 でセグメント動画を生成して ffmpeg で 1 本に結合する
という流れを実行できます。
セグメント数を増やせば、数十秒〜数分程度まで尺を伸ばせる構成になっています。
(レート制限やコスト次第で、さらに長くすることも理論上は可能です)
(UIまわりの動作が怪しいので直接実行がおすすめです)
パイプラインの全体像
流れはシンプルで、ざっくり 5 ステップです。
- Web UI でフレーム列(A, B, C, …)の説明と「動き/変化のメモ」を入力する
- (任意)参考画像を 1 枚アップロードして、スタイルの基準にする
- Gemini 2.5 Flash Image で各フレームの絵コンテ画像を生成(必要なフレームだけ再生成も可)
- 連続するフレームペアごとに、Veo 3.1 で短い動画セグメントを生成し、最後のフレームを次のペアの開始画像として繋いでいく
- すべてのセグメントを ffmpeg concat demuxer で連結して、1 本の MP4 にする
以下では、それぞれのステップをコードと一緒に見ていきます。
1. フレームプロンプト入力(Streamlit Web UI)
ファイル:app.py
Web アプリとしての入口は Streamlit です。ページタイトルや説明文を表示したうえで、
セッション状態に「フレームの配列」を持ち、そこに対して追加・挿入・削除・編集を行えるようになっています。
初期状態では A, B の 2 フレームだけが用意されます。
# app.py より抜粋
state = st.session_state
state.setdefault("run_dir", None)
state.setdefault("prompts_data", None)
state.setdefault("frame_paths", None)
state.setdefault("final_video_path", None)
state.setdefault("selected_frames", [])
state.setdefault("ref_path", None)
state.setdefault("use_fake_mode", use_fake_genai())
state.setdefault("frames", [{"id": "A", "prompt": ""}, {"id": "B", "prompt": ""}])
フレームは UI から増やしたり、途中に挿入したりできます。
# フレームの追加・挿入 UI
col_add, col_ins = st.columns(2)
with col_add:
new_prompt = st.text_input("末尾に追加するフレーム内容", key="add_prompt")
if st.button("末尾に追加"):
state.frames.append({"id": "Z", "prompt": new_prompt, "change_from_previous": ""})
_reindex_frames()
with col_ins:
if state.frames:
positions = [f'{idx+1}: {frame["id"]}' for idx, frame in enumerate(state.frames)]
pos = st.selectbox("挿入位置を選択", positions, index=0)
insert_before = positions.index(pos)
ins_prompt = st.text_input("挿入するフレーム内容", key="insert_prompt")
if st.button("選択位置の前に挿入"):
state.frames.insert(
insert_before, {"id": "Z", "prompt": ins_prompt, "change_from_previous": ""}
)
_reindex_frames()
各フレームは、
-
prompt: そのフレームの内容(テキストでシーンを説明) -
change_from_previous: 前フレームからの動き・カメラワークなどのメモ(任意)
の 2 つを持つシンプルな辞書として扱われます。
for idx, frame in enumerate(state.frames):
st.markdown(f"**Frame {frame['id']}**")
frame["prompt"] = st.text_area(
"フレーム説明", value=frame.get("prompt", ""), key=f"prompt_{frame['id']}", height=120
)
frame["change_from_previous"] = st.text_input(
"動き/変化のメモ(任意)",
value=frame.get("change_from_previous", ""),
key=f"change_{frame['id']}",
)
...
この UI がそのまま {"frames": [...]} という JSON 形にまとまり、
後続の絵コンテ生成・動画生成モジュールに渡されます。
2. 絵コンテ画像生成(Gemini 2.5 Flash Image)
ファイル:video_pipeline/images.py
ここでは、1 で入力された frames 情報から絵コンテ画像を生成します。
プロンプトの組み立て
_compose_image_prompt() はフレームごとのプロンプトをそのまま Image モデルへ渡します。
prompt が空の場合のみ、フォールバックとして change_from_previous を使います。
def _compose_image_prompt(frame: dict) -> str:
"""
Use the frame prompt provided by the user (minimal additions).
Fallback to change_from_previous if prompt text is missing.
"""
base_prompt = frame.get("prompt") or ""
if base_prompt:
return base_prompt
change = frame.get("change_from_previous")
return change or "incremental change from previous frame"
「1 フレーム目は基準シーンをフルで説明し、それ以外は差分だけを書く」という運用自体は同じですが、
ロジックとしては「ユーザーが書いたテキストをそのまま Gemini 2.5 Flash Image に投げる」だけのシンプル構造です。
参照画像のつなぎ方
generate_storyboard_images() は、参考画像+これまで生成したフレームを「ギャラリー」として毎回まとめて渡します。
def generate_storyboard_images(
prompts_data,
output_dir: Path,
ref_image_path: Optional[Union[Path, str]] = None,
*,
client=None,
config: Optional[PipelineConfig] = None,
) -> Dict[str, str]:
"""
Generate storyboard images for each frame prompt.
Returns a mapping of frame_id -> saved image path (as string).
"""
cfg = config or get_default_config()
genai_client = client or get_genai_client()
run_dir = Path(output_dir)
frames_dir = run_dir / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
reference_images: list[bytes] = []
if ref_image_path:
ref_path = Path(ref_image_path)
reference_images.append(ref_path.read_bytes())
generated_images: list[bytes] = []
frame_paths: Dict[str, str] = {}
frames = prompts_data.get("frames", [])
for frame in frames:
frame_id = frame.get("id") or "X"
prompt_text = _compose_image_prompt(frame)
ref_list = list(reference_images) + list(generated_images)
image_bytes = _generate_image_bytes(
prompt_text,
ref_list,
client=genai_client,
cfg=cfg,
)
image_path = frames_dir / f"frame_{frame_id}.png"
image_path.write_bytes(image_bytes)
frame_paths[frame_id] = str(image_path)
generated_images.append(image_bytes)
return frame_paths
ポイントはこの 2 つです。
- Frame A: 「参考画像(あれば)+なし」を渡して基準スタイルを決める
- Frame B 以降: 「参考画像+これまでのフレーム全部」を渡すことで
→ キャラ・背景・色味を保ったまま、ポーズやカメラだけを変えていく
さらに、regenerate_storyboard_images() を使うと「B だけ描き直したい」といったケースでも、
それ以前のフレームをすべて参照しながらターゲットフレームだけ再生成できます。
3. フレーム間の動画生成(Veo 3.1)
ファイル:video_pipeline/videos.py
1 ペアのセグメント生成
generate_segment_for_pair() は、
-
frame1_path(開始フレーム画像) -
frame2_path(終了フレーム画像) -
motion_description(「前フレームからどう動くか」のテキスト)
の 3 つから 1 本のセグメント動画を作ります。
def generate_segment_for_pair(
frame1_path: Path,
frame2_path: Path,
motion_description: str,
output_path: Path,
*,
client=None,
config: Optional[PipelineConfig] = None,
) -> str:
cfg = config or get_default_config()
genai_client = client or get_genai_client()
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
fake_mode = _is_fake_mode(genai_client)
prompt_text = (
"Create a short, smooth video segment that starts from the first frame and moves toward the second frame. "
"Maintain the same character, art style, camera framing, lighting, and world details across the segment. "
f"Motion description: {motion_description or 'natural, subtle motion continuing the scene.'}"
)
duration_seconds = cfg.segment_duration_seconds
# Veo interpolation only supports 8-second clips in real mode.
if not fake_mode and duration_seconds != 8:
duration_seconds = 8
# ここで genai_client.models.generate_videos(...) を呼び出し、
# 完了するまでポーリングして MP4 を output_path にダウンロードする
PipelineConfig.segment_duration_seconds はデフォルト 8 秒になっており、
REAL モードでは Veo の制約に合わせて 8 秒クリップとして生成されます。
セグメントをつなげていく
generate_all_segments() はフレーム列全体を見ながら、A→B, B→C, ... とセグメントを並べていきます。
def generate_all_segments(
frame_image_paths: Dict[str, str],
prompts_data,
output_dir: Path,
*,
client=None,
config: Optional[PipelineConfig] = None,
) -> List[str]:
cfg = config or get_default_config()
genai_client = client or get_genai_client()
segments_dir = Path(output_dir) / "segments"
segments_dir.mkdir(parents=True, exist_ok=True)
frames = prompts_data.get("frames", [])
if len(frames) < 2:
raise ValueError("At least 2 frames are required to generate segments.")
clip_paths: List[str] = []
first_frame = frames[0]
first_id = first_frame.get("id") or "F0"
current_start_image = Path(frame_image_paths[first_id])
for idx in range(len(frames) - 1):
second = frames[idx + 1]
second_id = second.get("id") or f"F{idx+1}"
second_path = Path(frame_image_paths[second_id])
motion_description = second.get("change_from_previous") or "smooth continuation"
segment_path = segments_dir / f"segment_{idx:03d}_{first_id}_{second_id}.mp4"
generated = generate_segment_for_pair(
current_start_image,
second_path,
motion_description,
segment_path,
client=genai_client,
config=cfg,
)
clip_paths.append(generated)
# 生成したセグメントの「最後のフレーム」を次のセグメントの開始画像に差し替える
last_frame_image = segments_dir / f"segment_{idx:03d}_{first_id}_{second_id}_last.png"
current_start_image = extract_last_frame(Path(generated), last_frame_image)
first_id = second_id
return clip_paths
ここでのポイント:
-
change_from_previousをmotion_descriptionとして渡しているので、
「ここでカメラを寄せる」「ここでキャラを振り向かせる」といった意図をテキストで伝えられる - 各セグメント生成後に
extract_last_frame()で最後のフレーム画像を抜き出し、
次のペアのframe1として使用することで、セグメント境界のカクつきを減らしている
結果として、「8 秒セグメントをつなぎ合わせた長尺動画」なのに、
見た目としては 1 本のアニメーションとして自然につながる構成になっています。
4. ffmpeg で連結して 1 本にまとめる
ファイル:video_pipeline/ffmpeg_utils.py
動画セグメントの連結には、ffmpeg の concat demuxer をそのまま使っています。
def concat_clips(clip_paths: Iterable[Union[str, Path]], output_path: Path, *, reencode_on_failure: bool = True) -> str:
"""
Concatenate MP4 clips using ffmpeg concat demuxer.
"""
clip_list = [Path(p).resolve() for p in clip_paths]
if not clip_list:
raise ValueError("No clip paths provided for concatenation")
for clip in clip_list:
if not clip.exists():
raise FileNotFoundError(f"Clip not found: {clip}")
output_path = Path(output_path).resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
# UTF-8 でファイルリストを書き出す(Windows での日本語パス対策)
with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".txt", encoding="utf-8"
) as list_file:
for clip in clip_list:
list_file.write(_format_concat_line(clip))
list_file_path = Path(list_file.name)
try:
cmd = [
"ffmpeg",
"-y",
"-f", "concat",
"-safe", "0",
"-i", str(list_file_path),
"-c", "copy",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0 and reencode_on_failure:
# コーデックが混在していて copy に失敗したときは再エンコードにフォールバック
cmd = [
"ffmpeg",
"-y",
"-f", "concat",
"-safe", "0",
"-i", str(list_file_path),
"-c:v", "libx264",
"-c:a", "aac",
"-movflags", "+faststart",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg concat failed: {result.stderr}")
finally:
list_file_path.unlink(missing_ok=True)
return str(output_path)
run_pipeline.build_video_from_frames() では、この concat_clips() に
generate_all_segments() で作った MP4 のリストを渡し、final.mp4 を作成するだけです。
やってみて分かったこと
仕組みとしてはうまく回っているものの、現実にはフレーム間の動画でも崩れることがあります。キャラの顔がふっと変わったり、背景だけ別物になったり、色が一瞬ズレたりする。モデル任せだと細部が揺れやすいからです。
今の構成でも長尺は十分作れますが、まだ改善の余地があります。絵コンテの揺れや動き指示のあいまいさがそのまま出るので、崩れやすい箇所だけ自動で検知して作り直す処理や、キャラの一貫性をより強く保つ仕組みを考えることが必要です。