Spaces:
Sleeping
Sleeping
File size: 33,786 Bytes
13329bb e0a2297 13329bb 0322022 13329bb d262ace 13329bb 0322022 13329bb 70bd6f3 3cf57a0 70bd6f3 3cf57a0 c7fef14 3cf57a0 c7fef14 3cf57a0 0322022 70bd6f3 13329bb 3cf57a0 e0a2297 3cf57a0 0322022 3cf57a0 0322022 3cf57a0 70bd6f3 0d81de6 0322022 c9d88ad 3cf57a0 0322022 c9d88ad 0322022 e0a2297 0322022 6b16a65 0322022 c7fef14 6b16a65 e0a2297 6b16a65 0322022 c7fef14 6b16a65 b42f324 e0a2297 3cf57a0 0322022 3cf57a0 b42f324 e0a2297 3cf57a0 13329bb 3cf57a0 c7fef14 3cf57a0 0322022 e0a2297 c7fef14 3cf57a0 0322022 3cf57a0 0322022 3cf57a0 e0a2297 c7fef14 6b16a65 c7fef14 6b16a65 c7fef14 6b16a65 c7fef14 398d00e 0322022 c9d88ad 0322022 398d00e e0a2297 0322022 6b16a65 c9d88ad 3cf57a0 c7fef14 43779c5 c7fef14 3cf57a0 c7fef14 3cf57a0 0322022 c7fef14 3cf57a0 c7fef14 3cf57a0 c7fef14 3cf57a0 0d81de6 3cf57a0 0322022 9b90fe3 c7fef14 3cf57a0 c7fef14 3cf57a0 c7fef14 3cf57a0 c7fef14 9b90fe3 3cf57a0 c7fef14 3cf57a0 c7fef14 9b90fe3 3cf57a0 c7fef14 3cf57a0 c7fef14 3cf57a0 0322022 c7fef14 e0a2297 0322022 9b90fe3 c7fef14 0322022 c9d88ad 0322022 c7fef14 e0a2297 c7fef14 3cf57a0 c7fef14 3cf57a0 0322022 c7fef14 e0a2297 0322022 9b90fe3 c9d88ad 0322022 9b90fe3 0322022 9b90fe3 0322022 9b90fe3 3cf57a0 e0a2297 0322022 9b90fe3 c9d88ad 0322022 9b90fe3 c7fef14 3cf57a0 c9d88ad 0322022 3cf57a0 0322022 3cf57a0 e0a2297 c7fef14 3cf57a0 0322022 3cf57a0 0322022 3cf57a0 0322022 3cf57a0 0322022 7bde585 0322022 7bde585 0322022 7bde585 0322022 7bde585 0322022 c9d88ad 7bde585 0322022 7bde585 0322022 3cf57a0 e0a2297 c7fef14 3cf57a0 c9d88ad 0322022 3cf57a0 0322022 3cf57a0 0322022 3cf57a0 7bde585 0322022 7bde585 0322022 3cf57a0 7bde585 3cf57a0 e0a2297 c7fef14 3cf57a0 c7fef14 3cf57a0 e0a2297 0322022 c7fef14 3cf57a0 13329bb fd8e6e2 13329bb fd8e6e2 13329bb 0322022 13329bb 0322022 13329bb 0322022 13329bb 0322022 13329bb 0322022 13329bb 0322022 c9d88ad 7bde585 c9d88ad 7bde585 c9d88ad 7bde585 0322022 c9d88ad 13329bb 0322022 13329bb 7bde585 13329bb 7bde585 c9d88ad 13329bb 02829ef c9d88ad 13329bb 7bde585 13329bb 0322022 98ff848 0322022 98ff848 e166719 0322022 7bde585 c653ff7 e166719 c653ff7 e166719 c653ff7 02829ef c653ff7 02829ef 7bde585 c653ff7 0322022 c653ff7 02829ef c653ff7 02829ef c653ff7 02829ef c653ff7 02829ef 7bde585 c653ff7 7bde585 c653ff7 7bde585 0322022 c653ff7 0322022 13329bb 7bde585 13329bb c9d88ad 02829ef 7bde585 c653ff7 c9d88ad e166719 0322022 13329bb |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 |
"""
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)
@app.route('/')
def index():
return render_template_string(INDEX_HTML)
@app.route('/upload', methods=['POST'])
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)
@app.route('/media/<path:filename>')
def media(filename):
return send_from_directory(UPLOAD_DIR, filename, as_attachment=False)
@app.route('/convert', methods=['POST'])
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) |