はじめに
画像と音声から動画を作成するアプリを作りました
開発環境
- 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
💬 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を調整
💬 タイトルの位置が微妙ですね

タイトルを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
実行
お疲れ様でした。
リポジトリはこちら


