4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Nano Bananaで絵コンテ生成 → Veo 3.1でアニメ化するWebアプリを作ってみた🍌

4
Last updated at Posted at 2025-12-06

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 アプリでは、ブラウザ上で

  1. フレームごとの説明テキストを追加/挿入する
  2. Gemini 2.5 Flash Image で絵コンテを 1 フレームずつ生成・再生成する
  3. すべてのフレームの画像が揃ったら、Veo 3.1 でセグメント動画を生成して ffmpeg で 1 本に結合する

という流れを実行できます。

セグメント数を増やせば、数十秒〜数分程度まで尺を伸ばせる構成になっています。
(レート制限やコスト次第で、さらに長くすることも理論上は可能です)

(UIまわりの動作が怪しいので直接実行がおすすめです)

パイプラインの全体像

流れはシンプルで、ざっくり 5 ステップです。

  1. Web UI でフレーム列(A, B, C, …)の説明と「動き/変化のメモ」を入力する
  2. (任意)参考画像を 1 枚アップロードして、スタイルの基準にする
  3. Gemini 2.5 Flash Image で各フレームの絵コンテ画像を生成(必要なフレームだけ再生成も可)
  4. 連続するフレームペアごとに、Veo 3.1 で短い動画セグメントを生成し、最後のフレームを次のペアの開始画像として繋いでいく
  5. すべてのセグメントを 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_previousmotion_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 を作成するだけです。

やってみて分かったこと

仕組みとしてはうまく回っているものの、現実にはフレーム間の動画でも崩れることがあります。キャラの顔がふっと変わったり、背景だけ別物になったり、色が一瞬ズレたりする。モデル任せだと細部が揺れやすいからです。

今の構成でも長尺は十分作れますが、まだ改善の余地があります。絵コンテの揺れや動き指示のあいまいさがそのまま出るので、崩れやすい箇所だけ自動で検知して作り直す処理や、キャラの一貫性をより強く保つ仕組みを考えることが必要です。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?