Spaces:
Running
Running
<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> |