video-music-sync / index.html
soiz1's picture
Update index.html
19aba94 verified
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>動画音声同期エディター</title>
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.upload-section {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 20px;
}
.file-upload {
border: 2px dashed #ccc;
padding: 20px;
text-align: center;
border-radius: 8px;
cursor: pointer;
}
.file-upload:hover {
border-color: #3498db;
}
button {
background-color: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.timeline-container {
display: flex;
flex-direction: column;
gap: 20px;
margin: 20px 0;
}
.timeline {
display: flex;
overflow-x: auto;
height: 100px;
background: #f0f0f0;
border-radius: 4px;
position: relative;
}
.video-timeline {
height: 120px;
}
.frame {
flex-shrink: 0;
width: 100px;
height: 100%;
position: relative;
cursor: grab;
}
.frame img {
width: 100%;
height: 100%;
object-fit: cover;
}
.audio-segment {
flex-shrink: 0;
width: 5px;
background: #3498db;
align-self: flex-end;
}
.preview-section {
display: flex;
flex-direction: column;
gap: 10px;
margin: 20px 0;
}
video, audio {
width: 100%;
border-radius: 4px;
}
.controls {
display: flex;
gap: 10px;
margin: 10px 0;
}
select {
padding: 8px;
border-radius: 4px;
border: 1px solid #ccc;
}
.export-options {
display: flex;
gap: 10px;
margin-top: 20px;
}
.loading {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
z-index: 100;
}
.loading.active {
display: flex;
}
.spinner {
border: 5px solid #f3f3f3;
border-top: 5px solid #3498db;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<h1>動画音声同期エディター</h1>
<div id="app">
<div class="upload-section" id="uploadSection">
<div class="file-upload" id="videoUpload">
<p>動画をアップロード</p>
<input type="file" id="videoInput" accept="video/*" style="display: none;">
</div>
<div class="file-upload" id="audioUpload">
<p>音声をアップロード</p>
<input type="file" id="audioInput" accept="audio/*" style="display: none;">
</div>
<button id="startEditBtn" disabled>編集を開始</button>
</div>
<div id="editorSection" style="display: none;">
<div class="controls">
<select id="syncMode">
<option value="videoToAudio">動画を音声に同期</option>
<option value="audioToVideo">音声を動画に同期</option>
</select>
</div>
<div class="timeline-container">
<h3>動画タイムライン</h3>
<div class="timeline video-timeline" id="videoTimeline"></div>
<h3>音声波形</h3>
<div class="timeline audio-timeline" id="audioTimeline"></div>
</div>
<div class="preview-section">
<h3>プレビュー</h3>
<video id="previewVideo" controls></video>
<audio id="previewAudio" controls></audio>
</div>
<div class="export-options">
<button id="exportVideo">動画のみエクスポート</button>
<button id="exportAudio">音声のみエクスポート</button>
<button id="exportCombined">結合してエクスポート</button>
</div>
</div>
</div>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
</div>
<script>
// アプリケーションの状態
const state = {
videoFile: null,
audioFile: null,
videoFrames: [],
audioWaveform: [],
ffmpeg: null,
isDragging: false,
dragStart: null,
dragEnd: null,
videoDuration: 0,
audioDuration: 0
};
// DOM要素
const elements = {
videoUpload: document.getElementById('videoUpload'),
audioUpload: document.getElementById('audioUpload'),
videoInput: document.getElementById('videoInput'),
audioInput: document.getElementById('audioInput'),
startEditBtn: document.getElementById('startEditBtn'),
uploadSection: document.getElementById('uploadSection'),
editorSection: document.getElementById('editorSection'),
videoTimeline: document.getElementById('videoTimeline'),
audioTimeline: document.getElementById('audioTimeline'),
previewVideo: document.getElementById('previewVideo'),
previewAudio: document.getElementById('previewAudio'),
syncMode: document.getElementById('syncMode'),
exportVideo: document.getElementById('exportVideo'),
exportAudio: document.getElementById('exportAudio'),
exportCombined: document.getElementById('exportCombined'),
loading: document.getElementById('loading')
};
// イベントリスナーの設定
function setupEventListeners() {
elements.videoUpload.addEventListener('click', () => elements.videoInput.click());
elements.audioUpload.addEventListener('click', () => elements.audioInput.click());
elements.videoInput.addEventListener('change', handleVideoUpload);
elements.audioInput.addEventListener('change', handleAudioUpload);
elements.startEditBtn.addEventListener('click', startEditing);
elements.exportVideo.addEventListener('click', () => exportMedia('video'));
elements.exportAudio.addEventListener('click', () => exportMedia('audio'));
elements.exportCombined.addEventListener('click', () => exportMedia('combined'));
// ドラッグ&ドロップイベント
elements.videoTimeline.addEventListener('mousedown', startDrag);
elements.audioTimeline.addEventListener('mousedown', startDrag);
document.addEventListener('mousemove', handleDrag);
document.addEventListener('mouseup', endDrag);
}
// 動画アップロード処理
async function handleVideoUpload(e) {
const file = e.target.files[0];
if (!file) return;
state.videoFile = file;
elements.videoUpload.innerHTML = `<p>${file.name}</p>`;
checkReadyState();
// 動画プレビュー設定
const videoUrl = URL.createObjectURL(file);
elements.previewVideo.src = videoUrl;
// 動画の長さを取得
state.videoDuration = await getVideoDuration(videoUrl);
// フレーム抽出 (簡易版)
extractVideoFrames(videoUrl);
}
// 音声アップロード処理
async function handleAudioUpload(e) {
const file = e.target.files[0];
if (!file) return;
state.audioFile = file;
elements.audioUpload.innerHTML = `<p>${file.name}</p>`;
checkReadyState();
// 音声プレビュー設定
const audioUrl = URL.createObjectURL(file);
elements.previewAudio.src = audioUrl;
// 音声の長さを取得
state.audioDuration = await getAudioDuration(audioUrl);
// 波形データ生成 (簡易版)
generateAudioWaveform(audioUrl);
}
// 編集開始
async function startEditing() {
showLoading();
window.FFmpeg = window.FFmpegWasm;
// FFmpegの初期化 - createFFmpegをFFmpeg.createFFmpegに修正
if (!state.ffmpeg && window.FFmpeg) {
const { createFFmpeg } = window.FFmpeg;
state.ffmpeg = createFFmpeg({ log: true });
await state.ffmpeg.load();
} else if (!window.FFmpeg) {
alert("FFmpegライブラリの読み込みに失敗しました。ページをリロードしてください。");
hideLoading();
return;
}
elements.uploadSection.style.display = 'none';
elements.editorSection.style.display = 'block';
hideLoading();
}
// ドラッグ開始
function startDrag(e) {
if (!e.target.classList.contains('frame') && !e.target.classList.contains('audio-segment')) return;
state.isDragging = true;
state.dragStart = e.clientX;
e.preventDefault();
}
// ドラッグ中
function handleDrag(e) {
if (!state.isDragging) return;
state.dragEnd = e.clientX;
}
// ドラッグ終了
function endDrag() {
if (!state.isDragging) return;
state.isDragging = false;
// 同期処理を実行
if (state.dragStart && state.dragEnd) {
const distance = state.dragEnd - state.dragStart;
const timePerPixel = 0.1; // 仮の値
const timeDiff = distance * timePerPixel;
const syncMode = elements.syncMode.value;
if (syncMode === 'videoToAudio') {
adjustVideoSpeed(timeDiff);
} else {
adjustAudioSpeed(timeDiff);
}
}
state.dragStart = null;
state.dragEnd = null;
}
// 動画速度調整
async function adjustVideoSpeed(timeDiff) {
showLoading();
const speedFactor = calculateSpeedFactor(timeDiff, state.videoDuration, state.audioDuration);
// FFmpegで処理
await processWithFFmpeg({
type: 'adjustVideo',
speedFactor: speedFactor
});
hideLoading();
}
// 音声速度調整
async function adjustAudioSpeed(timeDiff) {
showLoading();
const speedFactor = calculateSpeedFactor(timeDiff, state.audioDuration, state.videoDuration);
// FFmpegで処理
await processWithFFmpeg({
type: 'adjustAudio',
speedFactor: speedFactor
});
hideLoading();
}
// メディアエクスポート
async function exportMedia(type) {
showLoading();
await processWithFFmpeg({
type: type
});
hideLoading();
}
// FFmpeg処理のラッパー
async function processWithFFmpeg(options) {
try {
const { ffmpeg } = state;
if (!ffmpeg) {
alert("FFmpegが初期化されていません");
hideLoading();
return;
}
// ファイルをFFmpegに書き込み
await writeFileToFFmpeg(ffmpeg, 'input.mp4', state.videoFile);
await writeFileToFFmpeg(ffmpeg, 'input.mp3', state.audioFile);
let command;
let outputFile;
switch(options.type) {
case 'adjustVideo':
command = [
'-i', 'input.mp4',
'-i', 'input.mp3',
'-filter_complex',
`[0:v]setpts=${1/options.speedFactor}*PTS[v];[1:a]atempo=${options.speedFactor}[a]`,
'-map', '[v]',
'-map', '[a]',
'output.mp4'
];
outputFile = 'output.mp4';
break;
case 'adjustAudio':
command = [
'-i', 'input.mp4',
'-i', 'input.mp3',
'-filter_complex',
`[0:v]setpts=${options.speedFactor}*PTS[v];[1:a]atempo=${1/options.speedFactor}[a]`,
'-map', '[v]',
'-map', '[a]',
'output.mp4'
];
outputFile = 'output.mp4';
break;
case 'video':
command = ['-i', 'input.mp4', '-c', 'copy', 'output.mp4'];
outputFile = 'output.mp4';
break;
case 'audio':
command = ['-i', 'input.mp3', '-c', 'copy', 'output.mp3'];
outputFile = 'output.mp3';
break;
case 'combined':
command = [
'-i', 'input.mp4',
'-i', 'input.mp3',
'-c:v', 'copy',
'-c:a', 'aac',
'-shortest',
'output.mp4'
];
outputFile = 'output.mp4';
break;
}
await ffmpeg.run(...command);
// 結果を取得
const data = ffmpeg.FS('readFile', outputFile);
const mimeType = outputFile.endsWith('.mp4') ? 'video/mp4' : 'audio/mp3';
const blob = new Blob([data.buffer], { type: mimeType });
const url = URL.createObjectURL(blob);
// ダウンロード
const a = document.createElement('a');
a.href = url;
a.download = outputFile;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('FFmpeg処理エラー:', error);
alert('処理中にエラーが発生しました: ' + error.message);
}
}
// FFmpegにファイルを書き込むヘルパー関数
async function writeFileToFFmpeg(ffmpeg, filename, file) {
const fileData = await readFileAsArrayBuffer(file);
ffmpeg.FS('writeFile', filename, new Uint8Array(fileData));
}
// ヘルパー関数
function checkReadyState() {
elements.startEditBtn.disabled = !(state.videoFile && state.audioFile);
}
function showLoading() {
elements.loading.classList.add('active');
}
function hideLoading() {
elements.loading.classList.remove('active');
}
async function getVideoDuration(url) {
return new Promise((resolve) => {
const video = document.createElement('video');
video.src = url;
video.onloadedmetadata = () => {
resolve(video.duration);
URL.revokeObjectURL(url);
};
});
}
async function getAudioDuration(url) {
return new Promise((resolve) => {
const audio = document.createElement('audio');
audio.src = url;
audio.onloadedmetadata = () => {
resolve(audio.duration);
URL.revokeObjectURL(url);
};
});
}
function calculateSpeedFactor(timeDiff, sourceDuration, targetDuration) {
const ratio = targetDuration / sourceDuration;
const adjustedRatio = ratio * (1 + (timeDiff / sourceDuration));
return Math.max(0.5, Math.min(2, adjustedRatio)); // 0.5倍から2倍の範囲に制限
}
// 簡易版フレーム抽出
function extractVideoFrames(videoUrl) {
const video = document.createElement('video');
video.src = videoUrl;
video.crossOrigin = 'anonymous';
video.addEventListener('loadeddata', async () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 100;
canvas.height = 100;
const frames = [];
const frameCount = 20; // 簡易的に20フレーム
for (let i = 0; i < frameCount; i++) {
video.currentTime = (i / frameCount) * state.videoDuration;
await new Promise((resolve) => {
video.onseeked = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
frames.push({
time: video.currentTime,
thumbnail: canvas.toDataURL('image/jpeg')
});
resolve();
};
});
}
state.videoFrames = frames;
renderVideoFrames();
URL.revokeObjectURL(videoUrl);
});
}
// 簡易版波形生成
function generateAudioWaveform(audioUrl) {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
fetch(audioUrl)
.then(response => response.arrayBuffer())
.then(arrayBuffer => audioContext.decodeAudioData(arrayBuffer))
.then(audioBuffer => {
const length = audioBuffer.length;
const channelData = audioBuffer.getChannelData(0);
const segmentCount = 200;
const segmentSize = Math.floor(length / segmentCount);
const waveform = [];
for (let i = 0; i < segmentCount; i++) {
let sum = 0;
const start = i * segmentSize;
const end = start + segmentSize;
for (let j = start; j < end; j++) {
sum += Math.abs(channelData[j]);
}
const avg = sum / segmentSize;
waveform.push({
volume: Math.floor(avg * 100)
});
}
state.audioWaveform = waveform;
renderAudioWaveform();
URL.revokeObjectURL(audioUrl);
});
}
// フレームレンダリング
function renderVideoFrames() {
elements.videoTimeline.innerHTML = '';
state.videoFrames.forEach(frame => {
const frameEl = document.createElement('div');
frameEl.className = 'frame';
frameEl.draggable = true;
frameEl.innerHTML = `<img src="${frame.thumbnail}" alt="frame">`;
elements.videoTimeline.appendChild(frameEl);
});
}
// 波形レンダリング
function renderAudioWaveform() {
elements.audioTimeline.innerHTML = '';
state.audioWaveform.forEach(segment => {
const segmentEl = document.createElement('div');
segmentEl.className = 'audio-segment';
segmentEl.style.height = `${segment.volume}%`;
segmentEl.draggable = true;
elements.audioTimeline.appendChild(segmentEl);
});
}
// ファイル読み込みヘルパー
function readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
// 初期化
setupEventListeners();
</script>
</body>
</html>