Spaces:
Sleeping
Sleeping
""" | |
Flask 音声同期エディター(波形なし/2本バー+線マッピング) | |
▶ 使い方 | |
1) 必要: Python 3.9+ / FFmpeg が PATH で使えること | |
2) 依存関係: pip install Flask | |
3) 実行: python app.py | |
4) ブラウザで http://127.0.0.1:5000 を開く | |
機能: | |
- 2つの音声をアップロード | |
- 横向きの2本バー (上=音声1, 下=音声2) | |
- 上下バー間にドラッグ&ドロップで線を引いて対応(アンカー)を作成 | |
- 複数線の斜め対応により区間毎の速度補正を計算 | |
- FFmpeg の atempo を区間ごとに適用 (分割→速度変更→結合) | |
- 再生/一時停止、拡大/縮小(時間スケール)、現在秒表示 | |
- 変換後、UI の音声2を処理後に差し替え、ダウンロード可能 | |
注意: | |
- atempo は 0.5〜2.0 の範囲。範囲外はチェインで分割適用。 | |
- 区間境界はハードカット (必要なら acrossfade 等の導入を検討) | |
- 簡易実装のため同時複数ユーザーは想定していません | |
""" | |
import math | |
import os | |
import shlex | |
import subprocess | |
import json | |
from pathlib import Path | |
from flask import ( | |
Flask, | |
jsonify, | |
render_template_string, | |
request, | |
send_from_directory, | |
) | |
BASE_DIR = Path(os.getcwd()).resolve() | |
UPLOAD_DIR = BASE_DIR / "uploads" | |
OUTPUT_DIR = BASE_DIR / "static" / "out" | |
UPLOAD_DIR.mkdir(parents=True, exist_ok=True) | |
OUTPUT_DIR.mkdir(parents=True, exist_ok=True) | |
app = Flask(__name__) | |
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024 # 1GB | |
INDEX_HTML = r""" | |
<!doctype html> | |
<html lang="ja"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<title>音声同期エディター (2本バー+線)</title> | |
<style> | |
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; color:#222; } | |
h1 { margin-top: 0; } | |
.row { display:flex; gap:12px; align-items:center; flex-wrap: wrap; } | |
.panel { padding:12px; border:1px solid #ddd; border-radius:12px; box-shadow:0 1px 4px rgba(0,0,0,.05); } | |
.bar-wrap { position:relative; border:1px dashed #bbb; border-radius:8px; padding:12px; } | |
canvas { display:block; } | |
#timelineContainer { position:relative; overflow:auto; height:240px; background:#fafafa; border-radius:12px; } | |
.label { font-size:12px; color:#555; margin:4px 0; } | |
.controls { position: sticky; top: 0; background: white; z-index: 100; padding: 8px 12px; border-bottom: 1px solid #eee; } | |
.controls button { padding:8px 12px; border-radius:8px; border:1px solid #ddd; background:white; cursor:pointer; } | |
.controls button:active { transform: translateY(1px); } | |
.controls button.active { background: #2b7fff; color: white; } | |
.pill { display:inline-block; padding:4px 8px; background:#eef; border-radius:999px; font-size:12px; margin-left:6px; } | |
input[type="file"] { padding:8px; border:1px solid #ccc; border-radius:8px; background:#fff; } | |
.footer { margin-top:16px; font-size:12px; color:#666; } | |
.grid { display:grid; grid-template-columns:1fr; gap:12px; } | |
.current-time-line { position: absolute; top: 0; width: 2px; background: red; height: 100%; z-index: 10; pointer-events: none; } | |
.bar-container { position: relative; } | |
.video-preview { width: 100%; max-width: 600px; margin-bottom: 12px; } | |
.video-container { display: flex; flex-direction: column; gap: 12px; margin-bottom: 12px; } | |
.hidden { display: none !important; } | |
</style> | |
</head> | |
<body> | |
<h1>音声同期エディター <span class="pill">バー+線</span></h1> | |
<div class="panel"> | |
<div class="row"> | |
<div> | |
<div>ファイル1を選択:</div> | |
<input type="file" id="file1" accept="audio/*,video/*"> | |
</div> | |
<div> | |
<div>ファイル2を選択:</div> | |
<input type="file" id="file2" accept="audio/*,video/*"> | |
</div> | |
<button id="btnUpload">アップロード</button> | |
<button id="btnMode" class="active">編集・追加モード</button> | |
<button id="btnClearMap">線を全消去</button> | |
</div> | |
</div> | |
<div class="video-container"> | |
<div id="video1Container" class="hidden"> | |
<div class="label">ファイル1 プレビュー (動画 - 音声なし)</div> | |
<video id="video1" class="video-preview" muted controls preload="metadata"></video> | |
</div> | |
<div id="video2Container" class="hidden"> | |
<div class="label">ファイル2 プレビュー (動画 - 音声なし)</div> | |
<video id="video2" class="video-preview" muted controls preload="metadata"></video> | |
</div> | |
</div> | |
<div class="controls"> | |
<button id="btnPlayPause">▶ 再生</button> | |
<button id="btnZoomIn">+拡大</button> | |
<button id="btnZoomOut">-縮小</button> | |
<span id="timeLabel">0.000 s</span> | |
<span class="pill">ズーム: <span id="zoomLabel">100 px/s</span></span> | |
</div> | |
<div id="timelineContainer" class="panel"> | |
<div class="bar-container"> | |
<div class="label">ファイル1 (基準タイムライン)</div> | |
<div class="bar-wrap"> | |
<canvas id="bar1" width="1200" height="60"></canvas> | |
<div id="currentTimeLine1" class="current-time-line" style="left: 0px; display: none;"></div> | |
</div> | |
</div> | |
<div class="bar-container"> | |
<div class="label">ファイル2 (速度調整対象)</div> | |
<div class="bar-wrap"> | |
<canvas id="bar2" width="1200" height="60"></canvas> | |
<div id="currentTimeLine2" class="current-time-line" style="left: 0px; display: none;"></div> | |
</div> | |
</div> | |
</div> | |
<div class="panel grid"> | |
<div class="row"> | |
<button id="btnConvert">変換</button> | |
<a id="downloadLink" href="#" download style="display:none;">ダウンロード</a> | |
</div> | |
<div> | |
<div class="label">音声プレビュー</div> | |
<audio id="audio1" controls preload="metadata" style="width:100%"></audio> | |
<audio id="audio2" controls preload="metadata" style="width:100%; margin-top:8px"></audio> | |
</div> | |
</div> | |
<div class="footer">操作: 編集モードでバーをクリックして対応点を作成。削除モードで点をクリックして削除。</div> | |
<script> | |
(function(){ | |
// 状態 | |
let pxPerSec = 100; // ズーム: 1秒あたりのピクセル | |
let audio1Dur = 0; // 秒 | |
let audio2Dur = 0; // 秒 | |
let mappings = []; // {t1, t2} | |
let isEditMode = true; | |
let selectedPoint1 = null; | |
let selectedPoint2 = null; | |
let isVideo1 = false; | |
let isVideo2 = false; | |
const bar1 = document.getElementById('bar1'); | |
const bar2 = document.getElementById('bar2'); | |
const ctx1 = bar1.getContext('2d'); | |
const ctx2 = bar2.getContext('2d'); | |
const currentTimeLine1 = document.getElementById('currentTimeLine1'); | |
const currentTimeLine2 = document.getElementById('currentTimeLine2'); | |
const audio1 = document.getElementById('audio1'); | |
const audio2 = document.getElementById('audio2'); | |
const video1 = document.getElementById('video1'); | |
const video2 = document.getElementById('video2'); | |
const video1Container = document.getElementById('video1Container'); | |
const video2Container = document.getElementById('video2Container'); | |
const file1 = document.getElementById('file1'); | |
const file2 = document.getElementById('file2'); | |
const btnUpload = document.getElementById('btnUpload'); | |
const btnPlayPause = document.getElementById('btnPlayPause'); | |
const btnZoomIn = document.getElementById('btnZoomIn'); | |
const btnZoomOut = document.getElementById('btnZoomOut'); | |
const btnConvert = document.getElementById('btnConvert'); | |
const btnClearMap = document.getElementById('btnClearMap'); | |
const btnMode = document.getElementById('btnMode'); | |
const zoomLabel = document.getElementById('zoomLabel'); | |
const timeLabel = document.getElementById('timeLabel'); | |
const downloadLink = document.getElementById('downloadLink'); | |
function fmtSec(s){ return (s||0).toFixed(3) + ' s'; } | |
function calcCanvasWidth(){ | |
const w = Math.max(1200, Math.ceil(pxPerSec * Math.max(audio1Dur||0, audio2Dur||0)) + 40); | |
[bar1, bar2].forEach(c=> c.width = w); | |
} | |
function drawBar(ctx, duration, isFirst){ | |
ctx.clearRect(0,0,ctx.canvas.width, ctx.canvas.height); | |
// 背景 | |
ctx.fillStyle = '#fff'; | |
ctx.fillRect(0,0,ctx.canvas.width, ctx.canvas.height); | |
// ガイド | |
ctx.strokeStyle = '#e3e3e3'; | |
ctx.lineWidth = 1; | |
for(let s=0; s<=duration; s+=1){ | |
const x = Math.round(s * pxPerSec) + 0.5; | |
ctx.beginPath(); | |
ctx.moveTo(x, 0); | |
ctx.lineTo(x, ctx.canvas.height); | |
ctx.stroke(); | |
if(s%5===0){ | |
ctx.fillStyle = '#666'; | |
ctx.fillText(s + 's', x+2, 12); | |
} | |
} | |
// バー本体 | |
ctx.fillStyle = '#dff1ff'; | |
ctx.fillRect(0, 20, Math.round(duration*pxPerSec), ctx.canvas.height-40); | |
// 対応点を描画 | |
const pointRadius = 6; | |
mappings.forEach((m, idx) => { | |
const x = isFirst ? xFromSec(m.t1) : xFromSec(m.t2); | |
ctx.fillStyle = '#2b7fff'; | |
ctx.beginPath(); | |
ctx.arc(x, ctx.canvas.height / 2, pointRadius, 0, Math.PI * 2); | |
ctx.fill(); | |
// 点の番号 | |
ctx.fillStyle = '#fff'; | |
ctx.font = '10px Arial'; | |
ctx.textAlign = 'center'; | |
ctx.textBaseline = 'middle'; | |
ctx.fillText(idx + 1, x, ctx.canvas.height / 2); | |
}); | |
// 選択中の点を描画 | |
if (isEditMode) { | |
if (isFirst && selectedPoint1 !== null) { | |
const x = xFromSec(selectedPoint1); | |
ctx.fillStyle = '#ff7a2b'; | |
ctx.beginPath(); | |
ctx.arc(x, ctx.canvas.height / 2, pointRadius, 0, Math.PI * 2); | |
ctx.fill(); | |
} else if (!isFirst && selectedPoint2 !== null) { | |
const x = xFromSec(selectedPoint2); | |
ctx.fillStyle = '#ff7a2b'; | |
ctx.beginPath(); | |
ctx.arc(x, ctx.canvas.height / 2, pointRadius, 0, Math.PI * 2); | |
ctx.fill(); | |
} | |
} | |
} | |
function secFromX(x){ return x / pxPerSec; } | |
function xFromSec(t){ return Math.round(t * pxPerSec); } | |
function syncMediaIfNeeded() { | |
// すべてのメディア要素を同期 | |
const mediaElements = [audio1, audio2]; | |
if (isVideo1) mediaElements.push(video1); | |
if (isVideo2) mediaElements.push(video2); | |
if (mediaElements.length < 2) return; | |
// 最初の要素をマスターとして使用 | |
const masterTime = mediaElements[0].currentTime; | |
// 他の要素をマスターに同期 | |
for (let i = 1; i < mediaElements.length; i++) { | |
const diff = Math.abs(mediaElements[i].currentTime - masterTime); | |
if (diff > 0.08) { | |
mediaElements[i].currentTime = masterTime; | |
} | |
} | |
} | |
function redraw(){ | |
calcCanvasWidth(); | |
drawBar(ctx1, audio1Dur||0, true); | |
drawBar(ctx2, audio2Dur||0, false); | |
zoomLabel.textContent = Math.round(pxPerSec) + ' px/s'; | |
} | |
function updateCurrentTimeLine() { | |
const time1 = audio1.currentTime || 0; | |
const time2 = audio2.currentTime || 0; | |
const x1 = xFromSec(time1); | |
const x2 = xFromSec(time2); | |
currentTimeLine1.style.left = x1 + 'px'; | |
currentTimeLine1.style.display = 'block'; | |
currentTimeLine2.style.left = x2 + 'px'; | |
currentTimeLine2.style.display = 'block'; | |
} | |
function findClosestPoint(x, isFirst) { | |
let bestIdx = -1; | |
let bestDist = 15; // ヒット半径 | |
mappings.forEach((m, idx) => { | |
const pointX = xFromSec(isFirst ? m.t1 : m.t2); | |
const dist = Math.abs(pointX - x); | |
if (dist < bestDist) { | |
bestDist = dist; | |
bestIdx = idx; | |
} | |
}); | |
return bestIdx; | |
} | |
// バークリック処理(点の追加/選択/削除) | |
function handleBarClick(e, isFirst) { | |
const rect = e.target.getBoundingClientRect(); | |
const x = e.clientX - rect.left + e.target.scrollLeft; | |
const t = secFromX(x); | |
if (isEditMode) { | |
// 編集モード: 点を選択または作成 | |
if (isFirst) { | |
selectedPoint1 = t; | |
if (selectedPoint2 !== null) { | |
// 両方の点が選択されたのでマッピングを追加 | |
mappings.push({ t1: selectedPoint1, t2: selectedPoint2 }); | |
selectedPoint1 = null; | |
selectedPoint2 = null; | |
redraw(); | |
} else { | |
redraw(); | |
} | |
} else { | |
selectedPoint2 = t; | |
if (selectedPoint1 !== null) { | |
// 両方の点が選択されたのでマッピングを追加 | |
mappings.push({ t1: selectedPoint1, t2: selectedPoint2 }); | |
selectedPoint1 = null; | |
selectedPoint2 = null; | |
redraw(); | |
} else { | |
redraw(); | |
} | |
} | |
} else { | |
// 削除モード: 最も近い点を探して削除 | |
const idx = findClosestPoint(x, isFirst); | |
if (idx !== -1) { | |
mappings.splice(idx, 1); | |
redraw(); | |
} | |
} | |
} | |
bar1.addEventListener('click', (e) => handleBarClick(e, true)); | |
bar2.addEventListener('click', (e) => handleBarClick(e, false)); | |
// バーの外側クリックで同期シーク | |
document.querySelectorAll('.bar-container').forEach((container) => { | |
container.addEventListener('click', (e) => { | |
const rect = container.getBoundingClientRect(); | |
const x = e.clientX - rect.left; | |
const time = secFromX(x); | |
// すべてのメディア要素を同じ位置へ | |
const mediaElements = [audio1, audio2]; | |
if (isVideo1) mediaElements.push(video1); | |
if (isVideo2) mediaElements.push(video2); | |
mediaElements.forEach(media => { | |
try { | |
media.currentTime = time; | |
} catch (err) { /* noop */ } | |
}); | |
updateCurrentTimeLine(); | |
}); | |
}); | |
// モード切り替え | |
btnMode.addEventListener('click', () => { | |
isEditMode = !isEditMode; | |
btnMode.textContent = isEditMode ? '編集・追加モード' : '削除モード'; | |
btnMode.classList.toggle('active', isEditMode); | |
selectedPoint1 = null; | |
selectedPoint2 = null; | |
redraw(); | |
}); | |
// ズーム | |
btnZoomIn.addEventListener('click', ()=>{ pxPerSec = Math.min(800, pxPerSec*1.25); redraw(); }); | |
btnZoomOut.addEventListener('click', ()=>{ pxPerSec = Math.max(10, pxPerSec/1.25); redraw(); }); | |
// 再生/停止トグル(すべてのメディアを同期) | |
btnPlayPause.addEventListener('click', ()=>{ | |
const mediaElements = [audio1, audio2]; | |
if (isVideo1) mediaElements.push(video1); | |
if (isVideo2) mediaElements.push(video2); | |
if (mediaElements.length === 0) return; | |
// すべてのメディアが停止中かチェック | |
const allPaused = mediaElements.every(media => media.paused); | |
if (allPaused) { | |
// 再生開始 - 最初のメディアの現在時刻を基準に | |
const currentTime = mediaElements[0].currentTime || 0; | |
// すべてのメディアを同じ位置に設定 | |
mediaElements.forEach(media => { | |
try { media.currentTime = currentTime; } catch (err) {} | |
}); | |
// 再生を命令 | |
mediaElements.forEach(media => { | |
const p = media.play(); | |
if (p && typeof p.catch === 'function') p.catch(()=>{}); | |
}); | |
btnPlayPause.textContent = '⏸ 停止'; | |
} else { | |
// 停止 | |
mediaElements.forEach(media => media.pause()); | |
btnPlayPause.textContent = '▶ 再生'; | |
} | |
}); | |
// メディア要素の状態に応じてボタン表示を整える | |
function refreshPlayButtonState() { | |
const mediaElements = [audio1, audio2]; | |
if (isVideo1) mediaElements.push(video1); | |
if (isVideo2) mediaElements.push(video2); | |
const anyPlaying = mediaElements.some(media => !media.paused); | |
btnPlayPause.textContent = anyPlaying ? '⏸ 停止' : '▶ 再生'; | |
} | |
// すべてのメディア要素にイベントリスナーを追加 | |
function setupMediaEventListeners() { | |
const mediaElements = [audio1, audio2, video1, video2]; | |
mediaElements.forEach(media => { | |
if (media) { | |
media.addEventListener('play', refreshPlayButtonState); | |
media.addEventListener('pause', refreshPlayButtonState); | |
media.addEventListener('ended', refreshPlayButtonState); | |
} | |
}); | |
} | |
// 時間表示とカーソル位置更新 | |
setInterval(()=>{ | |
const time = audio1.currentTime || 0; | |
timeLabel.textContent = fmtSec(time); | |
updateCurrentTimeLine(); | |
syncMediaIfNeeded(); | |
}, 100); | |
// アップロード | |
btnUpload.addEventListener('click', async ()=>{ | |
const f1 = file1.files[0]; | |
const f2 = file2.files[0]; | |
if(!f1 || !f2){ alert('ファイル1とファイル2を選択してください'); return; } | |
const fd = new FormData(); | |
fd.append('audio1', f1); | |
fd.append('audio2', f2); | |
const res = await fetch('/upload', { method:'POST', body: fd }); | |
const j = await res.json(); | |
if(!j.ok){ alert('アップロードに失敗: ' + j.error); return; } | |
// ファイルタイプに応じて表示を切り替え | |
isVideo1 = j.audio1_is_video || false; | |
isVideo2 = j.audio2_is_video || false; | |
// ファイル1の表示設定 | |
if (isVideo1) { | |
video1Container.classList.remove('hidden'); | |
video1.src = j.audio1_url; // 元の動画ファイル | |
audio1.src = j.audio1_audio_url || j.audio1_url; // 音声ファイルまたは元のファイル | |
} else { | |
video1Container.classList.add('hidden'); | |
audio1.src = j.audio1_url; | |
} | |
// ファイル2の表示設定 | |
if (isVideo2) { | |
video2Container.classList.remove('hidden'); | |
video2.src = j.audio2_url; // 元の動画ファイル | |
audio2.src = j.audio2_audio_url || j.audio2_url; // 音声ファイルまたは元のファイル | |
} else { | |
video2Container.classList.add('hidden'); | |
audio2.src = j.audio2_url; | |
} | |
// メタデータ読み込み | |
const loadMetadata = (media) => new Promise(resolve => { | |
if (media.readyState >= 1) { | |
resolve(media.duration || 0); | |
} else { | |
media.onloadedmetadata = () => resolve(media.duration || 0); | |
// タイムアウトも設定 | |
setTimeout(() => resolve(0), 3000); | |
} | |
}); | |
// 両方の音声のdurationを取得 | |
try { | |
const [dur1, dur2] = await Promise.all([ | |
loadMetadata(audio1), | |
loadMetadata(audio2) | |
]); | |
audio1Dur = dur1; | |
audio2Dur = dur2; | |
redraw(); | |
} catch (error) { | |
console.error('メタデータ読み込みエラー:', error); | |
alert('ファイルの読み込み中にエラーが発生しました。もう一度お試しください。'); | |
} | |
setupMediaEventListeners(); | |
}); | |
// 変換 | |
btnConvert.addEventListener('click', async ()=>{ | |
// audio1とaudio2のsrcが設定されているかチェック | |
if(!audio1.src || !audio2.src){ | |
alert('先にアップロードしてください'); | |
return; | |
} | |
const res = await fetch('/convert', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
mappings: mappings, | |
is_video2: isVideo2 | |
}) | |
}); | |
const j = await res.json(); | |
if(!j.ok){ | |
alert('変換失敗: ' + (j.error || '不明なエラー')); | |
return; | |
} | |
// 処理結果を適用 | |
if (isVideo2) { | |
if (j.output_video_url) { | |
video2.src = j.output_video_url + '?t=' + Date.now(); | |
} | |
if (j.output_audio_url) { | |
audio2.src = j.output_audio_url + '?t=' + Date.now(); | |
} else if (j.output_url) { | |
audio2.src = j.output_url + '?t=' + Date.now(); | |
} | |
} else { | |
audio2.src = j.output_url + '?t=' + Date.now(); | |
} | |
// ダウンロードリンク更新 | |
if (j.output_url) { | |
downloadLink.href = j.output_url; | |
downloadLink.download = j.output_filename || 'output'; | |
downloadLink.style.display = 'inline-block'; | |
} | |
alert('変換が完了しました。プレビューとダウンロードが可能です。'); | |
}); | |
// マッピング全消去 | |
btnClearMap.addEventListener('click', ()=>{ | |
mappings = []; | |
selectedPoint1 = null; | |
selectedPoint2 = null; | |
redraw(); | |
}); | |
// 初期設定 | |
setupMediaEventListeners(); | |
redraw(); | |
})(); | |
</script> | |
</body> | |
</html> | |
""" | |
def run_cmd(cmd): | |
"""サブプロセス実行のヘルパ (実行内容とログをprint)""" | |
print("=== 実行コマンド ===") | |
print(" ".join(shlex.quote(c) for c in cmd)) | |
proc = subprocess.run( | |
cmd, | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
check=False, | |
text=True | |
) | |
print("=== 標準出力 ===") | |
print(proc.stdout.strip()) | |
print("=== 標準エラー ===") | |
print(proc.stderr.strip()) | |
print("=== 戻り値 ===", proc.returncode) | |
return proc.returncode, proc.stdout.strip(), proc.stderr.strip() | |
def ffprobe_duration(path: Path) -> float: | |
"""ffprobe で秒数(float)を取得。失敗時は 0.""" | |
cmd = [ | |
"ffprobe", "-v", "error", | |
"-show_entries", "format=duration", | |
"-of", "default=noprint_wrappers=1:nokey=1", | |
str(path) | |
] | |
code, out, err = run_cmd(cmd) | |
if code == 0: | |
try: | |
return float(out) | |
except Exception: | |
return 0.0 | |
return 0.0 | |
def ffprobe_has_video(path: Path) -> bool: | |
"""ffprobe でビデオストリームがあるかチェック""" | |
cmd = [ | |
"ffprobe", "-v", "error", | |
"-select_streams", "v", | |
"-show_entries", "stream=codec_type", | |
"-of", "default=noprint_wrappers=1:nokey=1", | |
str(path) | |
] | |
code, out, err = run_cmd(cmd) | |
return code == 0 and "video" in out | |
def ffprobe_has_audio(path: Path) -> bool: | |
"""ffprobe でオーディオストリームがあるかチェック""" | |
cmd = [ | |
"ffprobe", "-v", "error", | |
"-select_streams", "a", | |
"-show_entries", "stream=codec_type", | |
"-of", "default=noprint_wrappers=1:nokey=1", | |
str(path) | |
] | |
code, out, err = run_cmd(cmd) | |
return code == 0 and "audio" in out | |
def atempo_chain(tempo: float) -> str: | |
"""atempo は 0.5〜2.0 の範囲制限があるため、複数段に分解してチェインする。""" | |
if tempo <= 0: | |
tempo = 1.0 | |
filters = [] | |
t = tempo | |
# 大きい場合は 2.0 で割り続ける | |
while t > 2.0: | |
filters.append("atempo=2.0") | |
t /= 2.0 | |
# 小さい場合は 0.5 で割って逆にする | |
while t < 0.5: | |
filters.append("atempo=0.5") | |
t /= 0.5 | |
# 最後の 0.5〜2.0 範囲を追加 | |
filters.append(f"atempo={t:.6f}") | |
return ",".join(filters) | |
def index(): | |
return render_template_string(INDEX_HTML) | |
def upload(): | |
f1 = request.files.get('audio1') | |
f2 = request.files.get('audio2') | |
if not f1 or not f2: | |
return jsonify(ok=False, error='audio1 と audio2 が必要です'), 400 | |
# 拡張子 | |
ext1 = os.path.splitext(f1.filename or '')[1] or '.wav' | |
ext2 = os.path.splitext(f2.filename or '')[1] or '.wav' | |
p1 = UPLOAD_DIR / f"audio1{ext1}" | |
p2 = UPLOAD_DIR / f"audio2{ext2}" | |
# 既存ファイルを削除 | |
for p in (p1, p2): | |
try: | |
if p.exists(): p.unlink() | |
except Exception: | |
pass | |
f1.save(p1) | |
f2.save(p2) | |
# ファイルタイプをチェック | |
audio1_is_video = ffprobe_has_video(p1) | |
audio2_is_video = ffprobe_has_video(p2) | |
# レスポンスデータの基本設定 | |
response_data = { | |
'ok': True, | |
'audio1_url': f"/media/{p1.name}", | |
'audio2_url': f"/media/{p2.name}", | |
'audio1_is_video': audio1_is_video, | |
'audio2_is_video': audio2_is_video, | |
} | |
# 動画ファイルの場合、音声のみのバージョンも作成(オプション) | |
try: | |
if audio1_is_video: | |
audio_only_path = UPLOAD_DIR / f"audio1_audio.wav" | |
if audio_only_path.exists(): | |
audio_only_path.unlink() | |
# 動画から音声を抽出 | |
cmd = [ | |
"ffmpeg", "-y", "-i", str(p1), | |
"-vn", "-acodec", "pcm_s16le", "-ar", "44100", | |
str(audio_only_path) | |
] | |
code, out, err = run_cmd(cmd) | |
if code == 0 and audio_only_path.exists(): | |
response_data['audio1_audio_url'] = f"/media/audio1_audio.wav" | |
if audio2_is_video: | |
audio_only_path = UPLOAD_DIR / f"audio2_audio.wav" | |
if audio_only_path.exists(): | |
audio_only_path.unlink() | |
# 動画から音声を抽出 | |
cmd = [ | |
"ffmpeg", "-y", "-i", str(p2), | |
"-vn", "-acodec", "pcm_s16le", "-ar", "44100", | |
str(audio_only_path) | |
] | |
code, out, err = run_cmd(cmd) | |
if code == 0 and audio_only_path.exists(): | |
response_data['audio2_audio_url'] = f"/media/audio2_audio.wav" | |
except Exception as e: | |
print(f"音声抽出エラー: {e}") | |
# エラーが発生しても続行 | |
return jsonify(response_data) | |
def media(filename): | |
return send_from_directory(UPLOAD_DIR, filename, as_attachment=False) | |
def convert_audio(): | |
data = request.get_json(silent=True) or {} | |
mappings = data.get('mappings') or [] | |
is_video2 = data.get('is_video2', False) | |
# 入力ファイル探索 | |
cand1 = sorted(UPLOAD_DIR.glob('audio1.*'), key=os.path.getmtime) | |
cand2 = sorted(UPLOAD_DIR.glob('audio2.*'), key=os.path.getmtime) | |
if not cand1 or not cand2: | |
return jsonify(ok=False, error='先に /upload にファイルを送信してください'), 400 | |
src1 = cand1[-1] | |
src2 = cand2[-1] | |
# 動画ファイルに音声ストリームがあるかチェック | |
has_audio_in_video = ffprobe_has_audio(src2) if is_video2 else True | |
dur1 = ffprobe_duration(src1) | |
dur2 = ffprobe_duration(src2) | |
if dur1 <= 0 or dur2 <= 0: | |
return jsonify(ok=False, error='ファイルの長さを取得できませんでした'), 400 | |
# アンカー生成: 0 と 終端を補完 | |
anchors = [] | |
anchors.append({ 't1': 0.0, 't2': 0.0 }) | |
# ユーザー指定のアンカー (t1 昇順) | |
for m in sorted(mappings, key=lambda x: x.get('t1', 0.0)): | |
try: | |
t1 = float(m.get('t1', 0.0)) | |
t2 = float(m.get('t2', 0.0)) | |
if 0 <= t1 <= dur1 and 0 <= t2 <= dur2: | |
anchors.append({'t1': t1, 't2': t2}) | |
except Exception: | |
pass | |
anchors.append({ 't1': dur1, 't2': dur2 }) | |
# 単調増加になるようにフィルタリング | |
filtered = [anchors[0]] | |
for a in anchors[1:]: | |
if a['t1'] > filtered[-1]['t1'] and a['t2'] > filtered[-1]['t2']: | |
filtered.append(a) | |
anchors = filtered | |
if len(anchors) < 2: | |
return jsonify(ok=False, error='有効な対応線がありません'), 400 | |
# セグメントごとに atempo を計算 | |
segs = [] # (start2, end2, tempo) | |
for i in range(len(anchors)-1): | |
s1, e1 = anchors[i]['t1'], anchors[i+1]['t1'] | |
s2, e2 = anchors[i]['t2'], anchors[i+1]['t2'] | |
src_len = max(0.0, e2 - s2) | |
dst_len = max(0.001, e1 - s1) # 0割回避 | |
if src_len <= 0: # 無効 | |
continue | |
tempo = src_len / dst_len # 入力/出力 | |
segs.append((s2, e2, tempo)) | |
if not segs: | |
return jsonify(ok=False, error='有効な区間が作成できませんでした'), 400 | |
# 出力ファイルの設定 | |
if is_video2: | |
output_path = OUTPUT_DIR / "adjusted_video2.mp4" | |
else: | |
output_path = OUTPUT_DIR / "adjusted_audio2.wav" | |
# 既存ファイルを削除 | |
if output_path.exists(): | |
try: | |
output_path.unlink() | |
except Exception: | |
pass | |
def video_tempo_chain(tempo): | |
"""映像用の速度調整チェーン(0.5-2.0の制限対応)""" | |
if tempo <= 0: | |
tempo = 1.0 | |
filters = [] | |
t = tempo | |
# 大きい場合は2.0で割り続ける | |
while t > 2.0: | |
filters.append("setpts=PTS/2.0") | |
t /= 2.0 | |
# 小さい場合は0.5で割って逆にする | |
while t < 0.5: | |
filters.append("setpts=PTS/0.5") | |
t /= 0.5 | |
# 最後の0.5〜2.0範囲を追加 | |
if abs(t - 1.0) > 0.001: # 1.0と大きく異なる場合のみ追加 | |
filters.append(f"setpts=PTS/{t:.6f}") | |
return ",".join(filters) if filters else "null" | |
# FFmpeg フィルタ構築 | |
if is_video2: | |
try: | |
# 映像と音声のフィルタを別々に構築 | |
video_filters = [] | |
audio_filters = [] | |
video_labels = [] | |
audio_labels = [] | |
for idx, (st, ed, tempo) in enumerate(segs): | |
v_lab = f"v{idx}" | |
a_lab = f"a{idx}" | |
video_tempo_filter = video_tempo_chain(tempo) | |
# 映像フィルタ: trim + setptsで速度調整 | |
video_filters.append( | |
f"[0:v]trim=start={st:.6f}:end={ed:.6f},setpts=PTS-STARTPTS,{video_tempo_filter}[{v_lab}]" | |
) | |
# 音声フィルタ: atrim + atempoで速度調整 | |
if has_audio_in_video: | |
atempo_f = atempo_chain(tempo) | |
audio_filters.append( | |
f"[0:a]atrim=start={st:.6f}:end={ed:.6f},asetpts=PTS-STARTPTS,{atempo_f}[{a_lab}]" | |
) | |
video_labels.append(f"[{v_lab}]") | |
if has_audio_in_video: | |
audio_labels.append(f"[{a_lab}]") | |
# concatフィルタ | |
if has_audio_in_video: | |
concat_inputs = "".join([f"{vl}{al}" for vl, al in zip(video_labels, audio_labels)]) | |
concat_filter = f"{concat_inputs}concat=n={len(segs)}:v=1:a=1[outv][outa]" | |
filter_complex = ";".join(video_filters + audio_filters + [concat_filter]) | |
cmd = [ | |
"ffmpeg", "-y", | |
"-i", str(src2), | |
"-filter_complex", filter_complex, | |
"-map", "[outv]", | |
"-map", "[outa]", | |
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", | |
"-c:a", "aac", "-b:a", "128k", | |
"-movflags", "+faststart", | |
str(output_path) | |
] | |
else: | |
# 音声がない場合 | |
concat_inputs = "".join(video_labels) | |
concat_filter = f"{concat_inputs}concat=n={len(segs)}:v=1:a=0[outv]" | |
filter_complex = ";".join(video_filters + [concat_filter]) | |
cmd = [ | |
"ffmpeg", "-y", | |
"-i", str(src2), | |
"-filter_complex", filter_complex, | |
"-map", "[outv]", | |
"-an", # 音声なし | |
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", | |
str(output_path) | |
] | |
code, out, err = run_cmd(cmd) | |
if code != 0: | |
return jsonify(ok=False, error=f"動画処理失敗: {err[:1000]}") | |
except Exception as e: | |
return jsonify(ok=False, error=f"動画処理エラー: {str(e)}") | |
else: | |
# 音声のみ処理(既存のまま) | |
filters = [] | |
labels = [] | |
for idx, (st, ed, tempo) in enumerate(segs): | |
atempo_f = atempo_chain(tempo) | |
lab = f"a{idx}" | |
f = ( | |
f"[0:a]atrim=start={st:.6f}:end={ed:.6f}," | |
f"asetpts=PTS-STARTPTS,{atempo_f}[{lab}]" | |
) | |
filters.append(f) | |
labels.append(f"[{lab}]") | |
concat = f"{''.join(labels)}concat=n={len(segs)}:v=0:a=1[outa]" | |
filter_complex = ";".join(filters + [concat]) | |
cmd = [ | |
"ffmpeg", "-y", | |
"-i", str(src2), | |
"-filter_complex", filter_complex, | |
"-map", "[outa]", | |
str(output_path) | |
] | |
code, out, err = run_cmd(cmd) | |
if code != 0: | |
return jsonify(ok=False, error=f"FFmpeg 失敗: {err[:4000]}") | |
response_data = { | |
'ok': True, | |
'output_url': f"/static/out/{output_path.name}", | |
'output_filename': output_path.name, | |
} | |
# 動画ファイルの場合、音声のみのバージョンも作成 | |
if is_video2 and has_audio_in_video: | |
try: | |
audio_only_path = OUTPUT_DIR / "adjusted_audio.wav" | |
if audio_only_path.exists(): | |
audio_only_path.unlink() | |
cmd_audio_extract = [ | |
"ffmpeg", "-y", "-i", str(output_path), | |
"-vn", "-acodec", "pcm_s16le", "-ar", "44100", | |
str(audio_only_path) | |
] | |
code, out, err = run_cmd(cmd_audio_extract) | |
if code == 0: | |
response_data['output_audio_url'] = f"/static/out/adjusted_audio.wav" | |
except: | |
pass | |
return jsonify(response_data) | |
if __name__ == '__main__': | |
app.run(host='0.0.0.0', port=7860, debug=True, threaded=True) |