1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像と音声から動画を作成するアプリつくーる(Vite + React + TS)

1
Last updated at Posted at 2025-12-08

はじめに

画像と音声から動画を作成するアプリを作りました

開発環境

  • MacBook Air M1, 2020
  • Cursor 2.1.50
  • nvm 0.39.7
  • Node.js 22.4.0

フォルダ構成

frontend: Vite + React + TypeScript のフロントエンド

my-imageaudio2video/
├── README.md # プロジェクトの説明書
└── frontend/ # フロントエンドアプリケーション
  ├── public/ # 静的ファイル
  │ └── vite.svg # Viteロゴ
  ├── src/ # ソースコード
  │ ├── assets/ # アセットファイル
  │ │ └── react.svg # Reactロゴ
  │ ├── components/ # Reactコンポーネント
  │ │ └── ImageAudioToVideo.tsx # メインコンポーネント
  │ ├── App.tsx # アプリケーションのルートコンポーネント
  │ ├── App.css # アプリケーションのスタイル
  │ ├── index.css # グローバルスタイル
  │ └── main.tsx # エントリーポイント
  ├── eslint.config.js # ESLint設定
  ├── index.html # HTMLエントリーポイント
  ├── package.json # 依存関係とスクリプト
  ├── package-lock.json # 依存関係のロックファイル
  ├── tsconfig.json # TypeScript設定(ベース)
  ├── tsconfig.app.json # TypeScript設定(アプリケーション)
  ├── tsconfig.node.json # TypeScript設定(Node.js)
  ├── vite.config.ts # Vite設定
  └── README.md # フロントエンドの説明書

導入

💬 frontendフォルダに vite, react, ts アプリをセットアップして

cd frontend
npm create vite@latest . -- --template react-ts

> npx
> create-vite . --template react-ts

│
◇  Use rolldown-vite (Experimental)?:
│  No
│
◇  Install with npm and start now?
│  No
│
◇  Scaffolding project in /frontend...
│
└  Done. Now run:

  npm install
  npm run dev

フロントエンドの起動

npm install
npm run dev

localhost:5173にアクセス
20251208-025305-b649418a.png

💬 ImageAudioToVideoを実装して下さい

ImageAudioToVideo.tsx
// src/components/ImageAudioToVideo.tsx
import React, { useRef } from "react";

export const ImageAudioToVideo: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const audioRef = useRef<HTMLAudioElement | null>(null);

  const makeVideo = async () => {
    const canvas = canvasRef.current!;
    const audio = audioRef.current!;
    const ctx = canvas.getContext("2d")!;

    // 描画(静止画をキャンバスに描くだけの例)
    const img = new Image();
    img.src = "/slide.jpg"; // public フォルダ等
    await new Promise((res) => (img.onload = res));
    canvas.width = img.width;
    canvas.height = img.height;
    ctx.drawImage(img, 0, 0);

    // Canvas の映像ストリーム
    const videoStream = (canvas as any).captureStream(30); // fps
    // Audio をオーディオ要素で再生して MediaElementAudioSourceNode 経由で MediaStream にする方法
    // ここでは audio.captureStream() が使えるブラウザ向け(Chrome で動く)
    // 代替: AudioContext + MediaStreamDestination を使って AudioBufferSourceNode を繋ぐ
    // 以下は単純化した例:
    // @ts-ignore
    const audioStream = (audio as any).captureStream?.() ?? null;
    if (!audioStream) {
      // AudioContext 経由のフォールバック
      const ac = new AudioContext();
      const src = ac.createMediaElementSource(audio);
      const dest = ac.createMediaStreamDestination();
      src.connect(dest);
      src.connect(ac.destination);
      // @ts-ignore
      audioStream = dest.stream;
    }

    // 映像と音声を結合
    const combined = new MediaStream([
      ...videoStream.getVideoTracks(),
      ...audioStream.getAudioTracks(),
    ]);

    const options = { mimeType: "video/webm;codecs=vp9,opus" };
    const recorder = new MediaRecorder(combined, options);
    const chunks: Blob[] = [];
    recorder.ondataavailable = (e) => chunks.push(e.data);
    recorder.onstop = () => {
      const blob = new Blob(chunks, { type: "video/webm" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = "output.webm";
      a.click();
      URL.revokeObjectURL(url);
    };

    // 再生 → 録画
    audio.currentTime = 0;
    recorder.start();
    audio.play();
    // 音声が終わったら recorder.stop()
    audio.onended = () => recorder.stop();
  };

  return (
    <div>
      <canvas ref={canvasRef} style={{ display: "block" }} />
      <audio ref={audioRef} src="/speech.mp3" />
      <button onClick={makeVideo}>動画作成</button>
    </div>
  );
};

💬 audio.wavとimage.jpgでもいいですか?
audio.wavとimage.jpgに対応するよう、コンポーネント内のファイル名を更新

💬 ファイルはアップロードして作成、動画も再生できた方が良くない?
ファイルアップロードと動画再生に対応

💬 ドラッグ&ドロップでもアップロードできるようにして
ドラッグ&ドロップ機能を追加

💬 左側でアップロード、右側で動画にして
左側にアップロード、右側に動画表示の2カラムレイアウトに変更

💬 画面の高さに収まるようにUIを調整して
画面の高さに収まるようにUIを調整

💬 タイトルの位置が微妙ですね
Attached_image.png
タイトルを2カラムの上に横断配置(コンパクト)

ImageAudioToVideo.tsx
import React, { useRef, useState } from "react";

export const ImageAudioToVideo: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const audioRef = useRef<HTMLAudioElement | null>(null);
  const videoRef = useRef<HTMLVideoElement | null>(null);
  
  const [imageFile, setImageFile] = useState<File | null>(null);
  const [audioFile, setAudioFile] = useState<File | null>(null);
  const [videoUrl, setVideoUrl] = useState<string | null>(null);
  const [isProcessing, setIsProcessing] = useState(false);
  const [imagePreview, setImagePreview] = useState<string | null>(null);
  const [isImageDragging, setIsImageDragging] = useState(false);
  const [isAudioDragging, setIsAudioDragging] = useState(false);

  const processImageFile = (file: File) => {
    if (file && file.type.startsWith('image/')) {
      setImageFile(file);
      const reader = new FileReader();
      reader.onload = (e) => {
        setImagePreview(e.target?.result as string);
      };
      reader.readAsDataURL(file);
    } else {
      alert('画像ファイルを選択してください');
    }
  };

  const processAudioFile = (file: File) => {
    if (file && file.type.startsWith('audio/')) {
      setAudioFile(file);
      const url = URL.createObjectURL(file);
      if (audioRef.current) {
        audioRef.current.src = url;
      }
    } else {
      alert('音声ファイルを選択してください');
    }
  };

  const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      processImageFile(file);
    }
  };

  const handleAudioChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      processAudioFile(file);
    }
  };

  // 画像ドラッグ&ドロップ
  const handleImageDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsImageDragging(true);
  };

  const handleImageDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsImageDragging(false);
  };

  const handleImageDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsImageDragging(false);
    
    const file = e.dataTransfer.files[0];
    if (file) {
      processImageFile(file);
    }
  };

  // 音声ドラッグ&ドロップ
  const handleAudioDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsAudioDragging(true);
  };

  const handleAudioDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsAudioDragging(false);
  };

  const handleAudioDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsAudioDragging(false);
    
    const file = e.dataTransfer.files[0];
    if (file) {
      processAudioFile(file);
    }
  };

  const makeVideo = async () => {
    if (!imageFile || !audioFile) {
      alert('画像と音声ファイルを両方アップロードしてください');
      return;
    }

    setIsProcessing(true);
    const canvas = canvasRef.current!;
    const audio = audioRef.current!;
    const ctx = canvas.getContext("2d")!;

    try {
      // 画像をキャンバスに描画
      const img = new Image();
      img.src = imagePreview || '';
      await new Promise<void>((resolve, reject) => {
        img.onload = () => resolve();
        img.onerror = () => reject(new Error('画像の読み込みに失敗しました'));
      });

      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0);

      // Canvas の映像ストリーム
      const videoStream = (canvas as any).captureStream(30); // fps

      // Audio ストリームの取得
      let audioStream: MediaStream;
      
      if ((audio as any).captureStream) {
        // @ts-ignore
        audioStream = (audio as any).captureStream();
      } else {
        // AudioContext 経由のフォールバック
        const ac = new AudioContext();
        const src = ac.createMediaElementSource(audio);
        const dest = ac.createMediaStreamDestination();
        src.connect(dest);
        src.connect(ac.destination);
        audioStream = dest.stream;
      }

      // 映像と音声を結合
      const combined = new MediaStream([
        ...videoStream.getVideoTracks(),
        ...audioStream.getAudioTracks(),
      ]);

      const options = { mimeType: "video/webm;codecs=vp9,opus" };
      const recorder = new MediaRecorder(combined, options);
      const chunks: Blob[] = [];

      recorder.ondataavailable = (e) => {
        if (e.data.size > 0) {
          chunks.push(e.data);
        }
      };

      recorder.onstop = () => {
        const blob = new Blob(chunks, { type: "video/webm" });
        const url = URL.createObjectURL(blob);
        setVideoUrl(url);
        setIsProcessing(false);
      };

      // 再生 → 録画
      audio.currentTime = 0;
      recorder.start();
      audio.play();

      // 音声が終わったら recorder.stop()
      audio.onended = () => {
        recorder.stop();
      };
    } catch (error) {
      console.error('動画作成エラー:', error);
      alert('動画の作成に失敗しました: ' + (error as Error).message);
      setIsProcessing(false);
    }
  };

  const downloadVideo = () => {
    if (videoUrl) {
      const a = document.createElement("a");
      a.href = videoUrl;
      a.download = "output.webm";
      a.click();
    }
  };

  const containerStyle: React.CSSProperties = {
    maxWidth: "1400px",
    margin: "0 auto",
    padding: "15px",
    display: "flex",
    gap: "20px",
    alignItems: "flex-start",
    height: "calc(100vh - 40px)",
    maxHeight: "calc(100vh - 40px)",
    overflow: "hidden",
  };

  const leftColumnStyle: React.CSSProperties = {
    flex: "1",
    minWidth: "400px",
    display: "flex",
    flexDirection: "column",
    height: "100%",
    overflowY: "auto",
    paddingRight: "10px",
  };

  const rightColumnStyle: React.CSSProperties = {
    flex: "1",
    minWidth: "400px",
    height: "100%",
    display: "flex",
    flexDirection: "column",
  };

  const inputGroupStyle: React.CSSProperties = {
    marginBottom: "15px",
  };

  const labelStyle: React.CSSProperties = {
    display: "block",
    marginBottom: "8px",
    fontWeight: "bold",
    color: "#333",
  };

  const inputStyle: React.CSSProperties = {
    width: "100%",
    padding: "8px",
    border: "1px solid #ddd",
    borderRadius: "4px",
    fontSize: "14px",
  };

  const buttonStyle: React.CSSProperties = {
    padding: "12px 24px",
    fontSize: "16px",
    backgroundColor: "#667eea",
    color: "white",
    border: "none",
    borderRadius: "6px",
    cursor: "pointer",
    fontWeight: "bold",
    transition: "background-color 0.3s",
    marginRight: "10px",
    marginBottom: "10px",
  };

  const buttonDisabledStyle: React.CSSProperties = {
    ...buttonStyle,
    backgroundColor: "#ccc",
    cursor: "not-allowed",
  };

  const dropZoneStyle: React.CSSProperties = {
    border: "2px dashed",
    borderColor: isImageDragging ? "#667eea" : "#ddd",
    borderRadius: "8px",
    padding: "15px",
    textAlign: "center",
    backgroundColor: isImageDragging ? "#f0f4ff" : "#fafafa",
    transition: "all 0.3s ease",
    cursor: "pointer",
    maxHeight: "250px",
    overflow: "hidden",
    display: "flex",
    flexDirection: "column",
    justifyContent: "center",
  };

  const audioDropZoneStyle: React.CSSProperties = {
    border: "2px dashed",
    borderColor: isAudioDragging ? "#667eea" : "#ddd",
    borderRadius: "8px",
    padding: "15px",
    textAlign: "center",
    backgroundColor: isAudioDragging ? "#f0f4ff" : "#fafafa",
    transition: "all 0.3s ease",
    cursor: "pointer",
    minHeight: "120px",
    display: "flex",
    flexDirection: "column",
    justifyContent: "center",
  };

  return (
    <div style={containerStyle}>
      {/* 左側:ファイルアップロード */}
      <div style={leftColumnStyle}>
        <div style={inputGroupStyle}>
          <label style={labelStyle}>画像ファイル (JPG, PNG等)</label>
          <div
            onDragOver={handleImageDragOver}
            onDragLeave={handleImageDragLeave}
            onDrop={handleImageDrop}
            style={dropZoneStyle}
            onClick={() => document.getElementById('image-input')?.click()}
          >
            <div style={{ marginBottom: "10px" }}>
              {imagePreview ? (
                <img
                  src={imagePreview}
                  alt="プレビュー"
                  style={{
                    maxWidth: "100%",
                    maxHeight: "200px",
                    border: "1px solid #ddd",
                    borderRadius: "8px",
                    objectFit: "contain",
                  }}
                />
              ) : (
                <>
                  <div style={{ fontSize: "36px", marginBottom: "8px" }}>📷</div>
                  <div style={{ color: "#666", marginBottom: "5px", fontSize: "14px" }}>
                    画像をドラッグ&ドロップ
                  </div>
                  <div style={{ color: "#999", fontSize: "12px" }}>
                    またはクリックしてファイルを選択
                  </div>
                </>
              )}
            </div>
            <input
              id="image-input"
              type="file"
              accept="image/*"
              onChange={handleImageChange}
              style={{ display: "none" }}
            />
          </div>
        </div>

        <div style={inputGroupStyle}>
          <label style={labelStyle}>音声ファイル (WAV, MP3等)</label>
          <div
            onDragOver={handleAudioDragOver}
            onDragLeave={handleAudioDragLeave}
            onDrop={handleAudioDrop}
            style={audioDropZoneStyle}
            onClick={() => document.getElementById('audio-input')?.click()}
          >
            {audioFile ? (
              <div style={{ color: "#666" }}>
                <div style={{ fontSize: "36px", marginBottom: "8px" }}>🎵</div>
                <div style={{ fontWeight: "bold", marginBottom: "5px", fontSize: "14px" }}>
                  選択中: {audioFile.name}
                </div>
                <div style={{ color: "#999", fontSize: "12px" }}>
                  クリックして別のファイルを選択
                </div>
              </div>
            ) : (
              <>
                <div style={{ fontSize: "36px", marginBottom: "8px" }}>🎵</div>
                <div style={{ color: "#666", marginBottom: "5px", fontSize: "14px" }}>
                  音声をドラッグ&ドロップ
                </div>
                <div style={{ color: "#999", fontSize: "12px" }}>
                  またはクリックしてファイルを選択
                </div>
              </>
            )}
            <input
              id="audio-input"
              type="file"
              accept="audio/*"
              onChange={handleAudioChange}
              style={{ display: "none" }}
            />
          </div>
        </div>

        {/* ボタン */}
        <div style={{ textAlign: "center", marginTop: "auto", paddingTop: "15px" }}>
          <button
            onClick={makeVideo}
            disabled={!imageFile || !audioFile || isProcessing}
            style={(!imageFile || !audioFile || isProcessing) ? buttonDisabledStyle : buttonStyle}
          >
            {isProcessing ? "動画作成中..." : "動画作成"}
          </button>
          {videoUrl && (
            <button
              onClick={downloadVideo}
              style={buttonStyle}
            >
              動画をダウンロード
            </button>
          )}
        </div>
      </div>

      {/* 右側:動画表示 */}
      <div style={rightColumnStyle}>
        {videoUrl ? (
          <div style={{ ...inputGroupStyle, height: "100%", display: "flex", flexDirection: "column" }}>
            <label style={labelStyle}>生成された動画</label>
            <video
              ref={videoRef}
              src={videoUrl}
              controls
              style={{
                width: "100%",
                height: "100%",
                maxHeight: "calc(100vh - 120px)",
                border: "1px solid #ddd",
                borderRadius: "8px",
                objectFit: "contain",
              }}
            />
          </div>
        ) : (
          <div style={{
            border: "2px dashed #ddd",
            borderRadius: "8px",
            padding: "40px 20px",
            textAlign: "center",
            backgroundColor: "#fafafa",
            color: "#999",
            height: "100%",
            display: "flex",
            flexDirection: "column",
            justifyContent: "center",
            alignItems: "center",
          }}>
            <div style={{ fontSize: "48px", marginBottom: "15px" }}>🎬</div>
            <div style={{ fontSize: "16px", marginBottom: "8px" }}>
              動画が生成されるとここに表示されます
            </div>
            <div style={{ fontSize: "13px" }}>
              左側でファイルをアップロードして<br />
              「動画作成」ボタンをクリックしてください
            </div>
          </div>
        )}
      </div>

      {/* キャンバス(非表示) */}
      <canvas
        ref={canvasRef}
        style={{ display: "none" }}
      />

      {/* 音声要素(非表示) */}
      <audio ref={audioRef} />
    </div>
  );
};
App.tsx
import { ImageAudioToVideo } from './components/ImageAudioToVideo'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>画像と音声から動画作成</h1>
      <ImageAudioToVideo />
    </div>
  )
}

export default App

実行

localhost:5173にアクセス
20251208-030154-a69330df.png

画像と音声をアップロードして、動画作成
20251208-030255-cf4128b1.png

お疲れ様でした。

リポジトリはこちら

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?