ホーム » JOB » ツール » Mulmocastに触発されて…AIでYouTubeショート動画を自作してみた!

Mulmocastに触発されて…AIでYouTubeショート動画を自作してみた!

JOB

AIを活用したプログラミングの記事を作成しています!

第1章: はじめに

1-1. この記事でわかること

こんにちは!今回は、私自身が「Mulmocast」という革新的な動画生成システムに大いに刺激を受け、AIを活用してYouTubeショート動画を自動生成するプログラムを自作してみた話をご紹介します。Cursorを使って試行錯誤しながら開発を進め、直近のニュース記事を題材にストーリーをAIが作成し、DALL-E 3やStable Diffusion 3で画像を生成、そして最終的にショート動画を自動作成するところまでをプログラムにしてみました。まだまだ改良の余地はたくさんあり「作ってみました!」という段階ですが、この挑戦のプロセスと現状を皆さんと共有したいと思います。大げさなアピールではなく、一人のなんちゃって開発者の試みとして、ぜひ読んでみていただけると幸いです。

1-2. 想定読者

  • AIを活用したコンテンツ生成に興味がある方
  • 動画自動生成の仕組みに興味があるプログラマー・クリエイター
  • Mulmocastのような先進的なプロジェクトに触発された開発者
  • DALL-E 3やStable Diffusion 3などの画像生成AIの活用事例を知りたい方
  • プログラミングで新しい挑戦をしてみたい方

第2章: 自作ショート動画生成プログラムの全体像

2-1. プログラムの構成とフロー

私が今回作成したYouTubeショート動画生成プログラムは、大きく分けて以下のステップで構成されています。

  1. データ収集: 直近のニュース記事などから動画の題材となる情報をスクレイピングで収集します。
  2. ストーリー生成: 収集した情報を元に、AIがショート動画向けのストーリーを自動で作成します。感情豊かなストーリーを作成するためのプロンプトも工夫しました。
  3. 画像生成: 生成されたストーリーの内容に合わせて、DALL-E 3やStable Diffusion 3といった画像生成AIを使って、動画の各シーンに合う画像を生成します。
  4. ナレーション生成: 生成されたストーリーテキストを元に、ナレーション音声を合成します(VOICE VOX API使用)。
  5. 動画合成: 生成された画像とナレーション、そしてBGMを組み合わせて、最終的なYouTubeショート動画を自動的に生成します。

このように、一連のプロセスをAIが自動で担うことで、手軽に動画コンテンツを作成できる仕組みを目指しました。

ユーザー入力(テーマ・キーワード)
         ↓
┌───────────────────────────┐
│ 1. データ収集・分析フェーズ                          │
│   - Data Collector(キーワード検索)                 │
│   - Data Screener(品質フィルタリング)              │
│   - Context Generator(コンテキスト生成)※ GPT統合  │
│     (注:article_analyzer.pyは不使用)              │
└───────────────────────────┘
         ↓
┌───────────────────────────┐
│ 2. コンテンツ生成フェーズ                            │
│   - Story Generator(感情的ストーリー生成)          │
│   - Create Output Folder(出力フォルダ作成)         │
│   - Create Story Excel(構造化データ生成)           │
└───────────────────────────┘
         ↓
┌───────────────────────────┐
│ 3. メディア生成フェーズ(並列処理可)                │ 
│   - Generate Images(DALL-E 3 等で画像生成)         │
│   - Generate Narration(VOICEVOXで音声生成)         │
│   - Select BGM(感情別BGM自動選択)                  │
└───────────────────────────┘
         ↓
┌───────────────────────────┐
│ 4. 動画合成フェーズ(FFmpeg)                        │
│   - パート動画生成(画像+音声)                      │
│   - BGM混合&ループ処理                              │
│   - 字幕SRT生成                                      │
│   - 字幕焼き付け                                     │
│   - 最終出力(BGM_sub フラグ付きファイル名)         │
└───────────────────────────┘
         ↓
     最終動画(MP4)
    ✅ ナレーション
    ✅ BGM(ループ対応)
    ✅ 字幕(日本語)
    ✅ 1024x1792(9:16)

2-2. 使用している主要技術

このプログラムでは、様々なAI技術やライブラリを組み合わせています。

  • 自然言語処理(NLP): ストーリー生成やナレーションスクリプトの作成において、AIがニュース記事を理解し、自然な文章を生成するために利用しています。GPT-API(モデルgpr-4o-mini)を使用
  • 画像生成AI: DALL-E 3やStable Diffusion 3を核として、ストーリーに沿った高品質なビジュアルコンテンツを生成しています。特にpath_d_implementationフォルダには、キャラクター画像生成やストーリー画像生成に関するモジュールを集約しています。
  • 音声合成技術: 生成されたテキストを、自然な音声として出力するために利用しています。
  • 動画編集ライブラリ(FFmpegなど): 生成された画像、音声、BGMを組み合わせて、タイムラインに沿った動画を効率的に合成するために活用しています。

これらの技術を組み合わせることで、自動化された動画生成パイプラインを構築しました。

第3章: Mulmocastからのインスピレーション

「Mulmocast」を知ったとき、私はその動画生成の可能性に大きな衝撃を受けました。特に、テキスト、画像、音声といったマルチモーダルな要素を統合し、高品質な動画を生成するアプローチは、私自身の創作意欲を強く刺激しました。Mulmocastは非常に高度なシステムですが、Cursorという強力な開発環境を使い、「私のようなプログラミング初心者でも何かできるのではないか?」という思いで、今回のYouTubeショート動画生成プログラムの開発に着手しました。まだまだMulmocastには及びませんが、一歩ずつ自分のアイデアを形にできたことは大きな喜びです。

Mulmocast(マルチモーダルキャスト) – 伝説のプログラマー中島聡氏が贈る次世代動画AI自動生成システム

第4章: 作ってみました!現状と今後の展望

現在、このプログラムは直近のニュース記事からストーリーを生成し、DALL-E 3やStable Diffusion 3で画像を生成、ナレーションと字幕を合成してショート動画を作成するプログラムまで作りました。作成した動画をご覧ください。

正直なところ、生成されるストーリーの質や、画像の微調整、動画全体のクオリティなど、まだまだ改良すべき点は山積しています。例えば、キャラクターの一貫性や、感情表現の豊かさ、動画のテンポ調整など、より人間らしい表現に近づけるためには、さらなるプロンプトエンジニアリングやAIモデルの選定、後処理の工夫が必要です。

しかし、AIが自動でここまでできるようになったことに、大きな可能性を感じています。今後も改良を行なって、さらにハイクオリティな自動動画生成プログラムを目指していきたいと考えています。

DALL-e 3で生成した画像(↓):
同じキャラクターで複数の画像が生成できない点が課題

Stable Diffusionで生成した画像(↓):
実写風は画像の修正が難しいのでアニメ風で作成させました

画像生成プログラム(一部)です。

def main():
    global OUTPUT_BASE_DIR
    OUTPUT_BASE_DIR = get_output_folder()
    
    if not REPLICATE_API_TOKEN:
        print("エラー: REPLICATE_API_TOKEN が設定されていません。")
        print(".env ファイルに REPLICATE_API_TOKEN を追加してください。")
        sys.exit(1)
    
    print("="*70)
    print("パスD: ストーリー画像生成プログラム(Stable Diffusion v3)")
    print("="*70)
    
    # キャラクター情報ファイルを読み込む(シード値のみ使用)
    character_seeds_path = os.path.join(OUTPUT_BASE_DIR, "character_seeds.json")
    
    if not os.path.exists(character_seeds_path):
        print(f"エラー: {character_seeds_path} が見つかりません。")
        print("character_image_generator_d.py を先に実行してください。")
        sys.exit(1)
    
    with open(character_seeds_path, "r", encoding="utf-8") as f:
        character_seeds_map = json.load(f)
    
    # generated_stories.json を読み込む
    stories_json_path = os.path.join(OUTPUT_BASE_DIR, "generated_stories.json")
    
    if not os.path.exists(stories_json_path):
        print(f"エラー: {stories_json_path} が見つかりません。")
        sys.exit(1)
    
    with open(stories_json_path, "r", encoding="utf-8") as f:
        generated_stories = json.load(f)
    
    # ストーリー画像を保存するディレクトリを作成
    story_image_dir = os.path.join(OUTPUT_BASE_DIR, "path_d_story_images")
    os.makedirs(story_image_dir, exist_ok=True)
    
    # 処理対象ストーリーを決定
    if STORY_INDICES:
        # 指定されたストーリーのみ処理
        stories_to_process = [(idx + 1, generated_stories[idx]) for idx in STORY_INDICES if idx < len(generated_stories)]
        print(f"\n📊 処理対象: ストーリー {', '.join([str(idx) for idx, _ in stories_to_process])} (合計 {len(stories_to_process)} 件)")
    else:
        # デフォルト: すべてのストーリーを処理
        stories_to_process = [(idx + 1, story) for idx, story in enumerate(generated_stories)]
        print(f"\n📊 処理対象: 全 {len(stories_to_process)} 件のストーリー")
    
    print(f"📁 出力フォルダ: {story_image_dir}\n")
    
    # 各ストーリーについて8枚の画像を生成
    for story_idx, story_entry in stories_to_process:
        story_data = story_entry.get("story", {})
        title = story_data.get("タイトル", f"Story_{story_idx}")
        
        # パート情報を取得(パート1〜パート8)
        parts = []
        for i in range(1, 9):
            part_key = f"パート{i}"
            if part_key in story_data:
                parts.append(story_data[part_key])
        
        # パートが見つからない場合のフォールバック
        if not parts:
            parts = story_data.get("parts", [])
        
        # 従来の persona もしくは新しい characters 情報を取得
        persona = story_data.get("persona", {})
        characters_info = story_data.get("characters", {})
        character_type = characters_info.get("character_type", "single")
        
        print(f"\n[{story_idx}/{len(generated_stories)}] {title}")
        print(f"  👥 登場人物: {character_type}")
        
        # キャラクター情報を取得(character_seeds_map から主人公のシード値を取得)
        # character_seeds_map のキー形式: story_1_protagonist, story_4_counterpart など
        
        # 単一キャラクターの場合は protagonist、複数の場合は最初のキャラクター
        if character_type == "single":
            char_key = f"story_{story_idx}_protagonist"
        else:
            # 複数キャラクターの場合は最初のキャラクター(protagonist)を使用
            char_key = f"story_{story_idx}_protagonist"
        
        char_info = character_seeds_map.get(char_key, {})
        seed = char_info.get("seed")
        
        if not seed:
            # キーが見つからない場合、利用可能なキーをログに出力
            available_keys = [k for k in character_seeds_map.keys() if f"story_{story_idx}" in k]
            if available_keys:
                # 最初に見つかったキーを使用
                char_key = available_keys[0]
                char_info = character_seeds_map.get(char_key, {})
                seed = char_info.get("seed")
                print(f"  💡 キーを修正: {char_key}")
            
            if not seed:
                print(f"  ⚠️  シード値が見つかりません(検索キー: {char_key})。スキップします。")
                continue
        
        print(f"  🌱 使用するシード値: {seed}")
        print(f"  📍 パート数: {len(parts)}")
        
        # 各パートについて画像を生成
        for part_idx, part_text in enumerate(parts, 1):
            # part_text は文字列(ストーリーのテキスト)
            narration = part_text if isinstance(part_text, str) else str(part_text)
            
            # Stable Diffusion v3 用のプロンプトを作成(アニメ風スタイル)
            # 従来の persona形式またはキャラクター形式に対応
            if character_type == "single" and characters_info:
                # 新しい複数登場人物対応の場合
                protagonist = characters_info.get("protagonist", {})
                persona_desc = protagonist.get("full_description", "")
                # ★案3実装:パート別シード値でキャラクター表情を変化させながら、ベースシード値でキャラクター一貫性を確保
                # シード値オフセット: Part1=0, Part2=100, Part3=200... で大きく異なる値を使用
                part_seed = seed + (part_idx - 1) * 100
            elif character_type == "multiple" and characters_info:
                # 複数登場人物の場合、パートごとにキャラクターを切り替え
                characters = characters_info.get("characters", [])
                if not characters:
                    # キャラクター情報がない場合のフォールバック
                    persona_desc = persona.get("appearance", "")
                    part_seed = seed + (part_idx - 1) * 100
                else:
                    # パートのインデックスに応じてキャラクターを選択
                    char_idx = (part_idx - 1) % len(characters)  # ラウンドロビン選択
                    focused_char = characters[char_idx]
                    persona_desc = focused_char.get("full_description", "")
                    # 複数キャラの場合、フォーカスキャラのシード値を使用
                    char_role = focused_char.get('role', f'char_{char_idx}')
                    char_key = f"story_{story_idx}_{char_role}"
                    focused_char_info = character_seeds_map.get(char_key, {})
                    base_seed = focused_char_info.get("seed", seed)
                    # ★案3実装:キャラクター別ベースシード + パート別オフセット
                    part_seed = base_seed + (part_idx - 1) * 100
                    # ★デバッグ情報を出力
                    if not focused_char_info:
                        print(f"      ⚠️  キャラクター情報がありません: {char_key}")
            else:
                # 従来の persona形式
                persona_desc = persona.get("appearance", "")
                part_seed = seed + (part_idx - 1) * 100
            
            # ★プロンプト最適化:ストーリーテキストから背景・表情キーワードを抽出
            # キーワードマッピング
            scene_keywords = ""
            emotion_keywords = ""
            
            narration_lower = narration.lower()
            # 背景キーワード抽出
            if any(word in narration_lower for word in ["建設", "現場", "工事", "足場", "労働", "働く"]):
                scene_keywords += "construction site, industrial scene, "
            if any(word in narration_lower for word in ["朝", "家族", "家", "別れ", "見送"]):
                scene_keywords += "morning light, home interior, family farewell, "
            if any(word in narration_lower for word in ["高所", "危険", "恐怖", "崩れ", "転落"]):
                scene_keywords += "high altitude, dangerous, dramatic lighting, crisis scene, "
            if any(word in narration_lower for word in ["悲しみ", "悲劇", "喪失", "死亡", "病院"]):
                scene_keywords += "emotional scene, somber atmosphere, "
            if any(word in narration_lower for word in ["決意", "希望", "新た", "継承", "夢"]):
                scene_keywords += "hopeful atmosphere, determination, bright light, "
            if any(word in narration_lower for word in ["音楽", "シンガー", "歌", "ファン", "舞台"]):
                scene_keywords += "music, concert, spotlight, stage, "
            
            # 表情キーワード抽出
            if any(word in narration_lower for word in ["活発", "元気", "仕事", "強い", "責任"]):
                emotion_keywords += "energetic expression, determined look, "
            if any(word in narration_lower for word in ["恐怖", "不安", "震え", "躊躇"]):
                emotion_keywords += "fearful expression, anxious face, tense, "
            if any(word in narration_lower for word in ["落ち着き", "経験", "知的", "親切", "優しい"]):
                emotion_keywords += "calm expression, thoughtful, wise, "
            if any(word in narration_lower for word in ["白髪", "包容力", "支える"]):
                emotion_keywords += "mature, compassionate expression, supporting presence, "
            if any(word in narration_lower for word in ["悲しみ", "痛み", "涙", "喪失"]):
                emotion_keywords += "tearful, sorrowful expression, melancholic, "
            if any(word in narration_lower for word in ["希望", "決意", "前向き", "明るい", "笑顔"]):
                emotion_keywords += "hopeful smile, determined expression, bright eyes, "
            
            # デフォルトキーワード(上記に該当しない場合)
            if not scene_keywords:
                scene_keywords = "cinematic scene, detailed environment, "
            if not emotion_keywords:
                emotion_keywords = "expressive face, emotional, "
            
            # アニメ風スタイルでストーリーに沿った背景・シーンを含めたプロンプト
            prompt = f"anime art style, beautiful illustration, manga style, {persona_desc}が{narration}。{emotion_keywords}{scene_keywords}expressive eyes, soft colors, emotional expression, hand-drawn, 9:16 aspect ratio, cinematic lighting, warm color grading, dynamic composition, story-focused scene"
            
            print(f"    Part{part_idx}: ", end="", flush=True)
            
            # API呼び出し間に遅延を追加(レート制限回避)
            time.sleep(10)  # 10秒待機でレート制限を回避
            
            # リトライロジック(429エラー対応)
            max_retries = 5
            success = False
            
            for attempt in range(max_retries):
                try:
                    # Stable Diffusion v3 でストーリー画像を生成
                    output = replicate.run(
                        "stability-ai/stable-diffusion-3",
                        input={
                            "prompt": prompt,
                            "num_outputs": 1,
                            "height": 1792,
                            "width": 1024,
                            "seed": part_seed,  # パート別シード値を使用してパート間で変化を持たせる
                            "output_format": "jpg"
                        }
                    )
                    
                    image_url = str(output[0])
                    
                    # ファイル名を生成(Windows互換)
                    sanitized_title = title.replace(" ", "_").replace(" ", "_")
                    # Windowsで使用不可な文字を除去
                    invalid_chars = '\\/:*?"<>|'
                    for char in invalid_chars:
                        sanitized_title = sanitized_title.replace(char, "")
                    
                    story_image_filename = f"{story_idx:02d}_{sanitized_title}_part{part_idx}.jpg"
                    story_image_path = os.path.join(story_image_dir, story_image_filename)
                    
                    # 画像をダウンロード
                    if download_image(image_url, story_image_path):
                        print(f"✅ ダウンロード完了")
                        
                        # メタデータをテキストファイルに保存
                        meta_path = story_image_path.replace(".jpg", ".txt")
                        with open(meta_path, "w", encoding="utf-8") as f:
                            f.write(f"Title: {title}\n")
                            f.write(f"Part: {part_idx}\n")
                            f.write(f"Image URL: {image_url}\n")
                            f.write(f"Seed: {part_seed}\n")  # ★修正:part_seed を使用(パート別シード値)
                            f.write(f"Character Type: {character_type}\n")  # ★追加:キャラクター情報
                            if character_type == "multiple" and characters_info:
                                f.write(f"Focused Character: {focused_char.get('name', 'Unknown')} ({focused_char.get('role', 'Unknown')})\n")
                            f.write(f"Narration: {narration}\n")
                        success = True
                        break
                    else:
                        print(f"❌ ダウンロード失敗(スキップ)")
                        success = True
                        break
                    
                except Exception as api_error:
                    error_str = str(api_error)
                    # 429エラー(レート制限)の場合
                    if "429" in error_str:
                        if attempt < max_retries - 1:
                            # 指数バックオフで待機(15秒 → 30秒 → 60秒 → 120秒 → 240秒)
                            wait_time = 15 * (2 ** attempt)
                            print(f"\n    ⚠️  レート制限エラー。{wait_time}秒待機後に再試行...")
                            time.sleep(wait_time)
                        else:
                            print(f"❌ レート制限({max_retries}回試行後スキップ)")
                            break
                    else:
                        # その他のエラーはスキップ
                        print(f"❌ エラー: {str(api_error)[:50]}")
                        break
    
    print(f"\n" + "="*70)
    print(f"✅ ストーリー画像生成完了!")
    print(f"="*70)
    print(f"📁 画像保存先: {story_image_dir}")
    print(f"\n💡 次のステップ:")
    print(f"  1. 生成された画像を確認")
    print(f"  2. generate_images.py でDALL-E 3 のストーリー画像を生成")
    print(f"  3. ffmpeg_video_composer.py で動画を合成")

if __name__ == "__main__":
    main()

第5章: まとめ

今回は、Mulmocastに触発されて、私がCursorを使って自作してみたAIによるYouTubeショート動画生成プログラムについてご紹介しました。ニュース記事を元にしたストーリー生成から、DALL-E 3/Stable Diffusion 3による画像生成、そして動画合成までの一連の流れを自動化する、という挑戦でした。まだ「作ってみました!」という段階ですが、AIがコンテンツ制作の強力なアシスタントになり得ることを改めて実感できました。このプログラムが、皆さんのAIを活用した創作活動の一助となれば幸いです。もし今回の内容が面白いと思っていただけたり、何かアドバイスなどありましたら反応して頂ければ幸いです!

Cursorの使い方は以下の動画が非常に参考になりました。

Agents | Cursor – The AI Code Editor

コメント

タイトルとURLをコピーしました