This commit is contained in:
DelLevin-Home
2026-06-17 10:43:33 +08:00
parent db033c661f
commit 05e733e872
18 changed files with 3399 additions and 254 deletions

View File

@@ -72,7 +72,20 @@
"Bash(cat)", "Bash(cat)",
"Bash(python /tmp/check_style.py)", "Bash(python /tmp/check_style.py)",
"Read(//tmp/**)", "Read(//tmp/**)",
"Bash(curl -s -o /dev/null -w \"HTTP Status: %{http_code}\\\\nSize: %{size_download} bytes\\\\n\" http://localhost:5000/uvr-sep/)" "Bash(curl -s -o /dev/null -w \"HTTP Status: %{http_code}\\\\nSize: %{size_download} bytes\\\\n\" http://localhost:5000/uvr-sep/)",
"Bash(python -c \"import torch.utils._pytree as pt; print\\(hasattr\\(pt, 'register_constant'\\)\\); print\\(dir\\(pt\\)\\)\")",
"Bash(python -c \"import unsloth; print\\(unsloth.__version__\\)\")",
"Bash(head -80 \"D:\\\\Environment\\\\python\\\\python3-12\\\\Lib\\\\site-packages\\\\unsloth\\\\__init__.py\" 2>&1)",
"Bash(python -c \"import bitsandbytes as bnb; print\\('bnb OK'\\); print\\(dir\\(bnb.functional.lib\\)\\)\")",
"Bash(ls \"D:\\\\Environment\\\\python\\\\python3-12\\\\Lib\\\\site-packages\\\\unsloth\\\\\" 2>&1 | head -30)",
"Bash(python -c \"from llama_cpp import Llama; print\\('OK'\\)\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5000/ai-translate/)",
"Bash(curl -s http://localhost:5000/ai-translate/models)",
"Bash(python -m json.tool)",
"Bash(python -c \"import llama_cpp; print\\('file:', llama_cpp.__file__\\); print\\('supports_gpu:', llama_cpp.llama_supports_gpu_offload\\(\\)\\)\")",
"Bash(cat \"E:/AI/ai-translation/argos/translate-en_zh-1_9/metadata.json\")",
"Bash(ls \"E:\\\\AI\\\\ai-translation\\\\argos\")",
"Bash(cat \"E:\\\\AI\\\\ai-translation\\\\nllb\\\\nllb-200-distilled-600M\\\\config.json\" 2>&1 | head -20)"
] ]
} }
} }

View File

@@ -5,16 +5,17 @@
""" """
import sys import sys
import os import os
import json
import time import time
from datetime import datetime from datetime import datetime
from flask import Flask, render_template, jsonify, g, request, session, redirect from flask import Flask, render_template, jsonify, g, request, session, redirect
from config import PORT, LOGIN_ENABLED, LOGIN_USERNAME, LOGIN_PASSWORD, SECRET_KEY from config import PORT, LOGIN_ENABLED, LOGIN_USERNAME, LOGIN_PASSWORD, SECRET_KEY
from blueprints import pin_tu_bp, base64_bp, down_video_bp, fen_ci_bp, content_tag_bp, chmod_calc_bp, json_format_bp, qr_code_bp, http_status_bp, url_parser_bp, token_gen_bp, sovits_tts_bp, stt_bp, ai_dubbing_bp, rvc_bp, audio_slicer_bp, uvr_sep_bp from blueprints import pin_tu_bp, base64_bp, down_video_bp, fen_ci_bp, content_tag_bp, chmod_calc_bp, json_format_bp, qr_code_bp, http_status_bp, url_parser_bp, token_gen_bp, sovits_tts_bp, stt_bp, ai_dubbing_bp, rvc_bp, audio_slicer_bp, uvr_sep_bp, mp4_to_audio_bp, ai_translate_bp
from utils.stats_db import init_db, record_call, get_total_count, get_avg_duration, get_daily_counts, get_monthly_counts, get_available_years, get_available_months, get_daily_counts_by_month, get_hourly_counts, get_endpoint_stats, get_today_count, get_yesterday_count, get_month_count, get_success_rate, get_duration_distribution, get_slowest_endpoints, get_week_compare, get_recent_calls, get_active_days from utils.stats_db import init_db, record_call, get_total_count, get_avg_duration, get_daily_counts, get_monthly_counts, get_available_years, get_available_months, get_daily_counts_by_month, get_hourly_counts, get_endpoint_stats, get_today_count, get_yesterday_count, get_month_count, get_success_rate, get_duration_distribution, get_slowest_endpoints, get_week_compare, get_recent_calls, get_active_days
from utils.stats_db import ALLOWED_TABLES, get_table_info, get_table_rows, get_table_count, insert_row, update_row, delete_rows, clear_table from utils.stats_db import ALLOWED_TABLES, get_table_info, get_table_rows, get_table_count, insert_row, update_row, delete_rows, clear_table
TOOL_COUNT = 17 TOOL_COUNT = 19
def create_app(): def create_app():
@@ -57,6 +58,24 @@ def create_app():
def data_manage(): def data_manage():
return render_template('data_manage.html') return render_template('data_manage.html')
# 侧边栏功能显示配置
SIDEBAR_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config', 'sidebar_config.json')
@app.route('/api/sidebar-config', methods=['GET'])
def get_sidebar_config():
if os.path.exists(SIDEBAR_CONFIG_PATH):
with open(SIDEBAR_CONFIG_PATH, 'r', encoding='utf-8') as f:
return jsonify(json.load(f))
return jsonify({'hidden_features': []})
@app.route('/api/sidebar-config', methods=['POST'])
def save_sidebar_config():
data = request.get_json(force=True)
os.makedirs(os.path.dirname(SIDEBAR_CONFIG_PATH), exist_ok=True)
with open(SIDEBAR_CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return jsonify({'success': True})
# 仪表盘统计接口 # 仪表盘统计接口
@app.route('/api/stats') @app.route('/api/stats')
def api_stats(): def api_stats():
@@ -228,6 +247,8 @@ def create_app():
app.register_blueprint(rvc_bp) app.register_blueprint(rvc_bp)
app.register_blueprint(audio_slicer_bp) app.register_blueprint(audio_slicer_bp)
app.register_blueprint(uvr_sep_bp) app.register_blueprint(uvr_sep_bp)
app.register_blueprint(mp4_to_audio_bp)
app.register_blueprint(ai_translate_bp)
return app return app

View File

@@ -16,9 +16,11 @@ from .ai_dubbing import bp as ai_dubbing_bp
from .rvc import bp as rvc_bp from .rvc import bp as rvc_bp
from .audio_slicer import bp as audio_slicer_bp from .audio_slicer import bp as audio_slicer_bp
from .uvr_sep import bp as uvr_sep_bp from .uvr_sep import bp as uvr_sep_bp
from .mp4_to_audio import bp as mp4_to_audio_bp
from .ai_translate import bp as ai_translate_bp
__all__ = [ __all__ = [
'pin_tu_bp', 'base64_bp', 'down_video_bp', 'fen_ci_bp', 'content_tag_bp', 'pin_tu_bp', 'base64_bp', 'down_video_bp', 'fen_ci_bp', 'content_tag_bp',
'chmod_calc_bp', 'json_format_bp', 'qr_code_bp', 'http_status_bp', 'url_parser_bp', 'token_gen_bp', 'chmod_calc_bp', 'json_format_bp', 'qr_code_bp', 'http_status_bp', 'url_parser_bp', 'token_gen_bp',
'sovits_tts_bp', 'stt_bp', 'ai_dubbing_bp', 'rvc_bp', 'audio_slicer_bp', 'uvr_sep_bp', 'sovits_tts_bp', 'stt_bp', 'ai_dubbing_bp', 'rvc_bp', 'audio_slicer_bp', 'uvr_sep_bp', 'mp4_to_audio_bp', 'ai_translate_bp',
] ]

View File

@@ -0,0 +1,621 @@
# -*- coding: utf-8 -*-
"""
ai-translate AI 翻译蓝图
支持 NLLB-200 本地翻译引擎
"""
import os
import re
import json
import uuid
import threading
import tempfile
from flask import Blueprint, render_template, request, jsonify, send_file
bp = Blueprint('ai_translate', __name__, url_prefix='/ai-translate')
try:
from config import BASE_DIR
except ImportError:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'ai_translate_config.json')
# 引擎状态
_model = None
_model_lock = threading.Lock()
_engine_status = {'loaded': False, 'loading': False, 'engine_type': None, 'model_name': None, 'error': None}
_tasks = {}
SUBTITLE_EXTS = ('.srt', '.ass', '.ssa', '.vtt')
LANGUAGES = {
'zh': '中文', 'en': '英文', 'ja': '日文', 'ko': '韩文',
'fr': '法文', 'de': '德文', 'es': '西班牙文', 'ru': '俄文',
'th': '泰文', 'vi': '越南文', 'it': '意大利文',
'pt': '葡萄牙文', 'auto': '自动检测',
}
# NLLB-200 语言代码映射
_NLLB_LANG_MAP = {
'zh': 'zho_Hans', 'en': 'eng_Latn', 'ja': 'jpn_Jpan', 'ko': 'kor_Hang',
'fr': 'fra_Latn', 'de': 'deu_Latn', 'es': 'spa_Latn', 'ru': 'rus_Cyrl',
'th': 'tha_Thai', 'vi': 'vie_Latn', 'it': 'ita_Latn',
'pt': 'por_Latn',
}
# langdetect 返回值 → 我们的语言代码
_DETECT_LANG_MAP = {
'zh-cn': 'zh', 'zh-tw': 'zh', 'zh': 'zh',
'en': 'en', 'ja': 'ja', 'ko': 'ko',
'fr': 'fr', 'de': 'de', 'es': 'es', 'ru': 'ru',
'th': 'th', 'vi': 'vi', 'it': 'it', 'pt': 'pt',
}
ENGINE_TYPES = {
'nllb': {'name': 'NLLB-200', 'desc': 'HF 目录或模型 ID'},
}
def _default_config():
return {
'engine_type': 'nllb',
'nllb_dir': '',
'max_new_tokens': 512,
'batch_size': 8,
'last_nllb_model': '',
'last_src_lang': 'en',
'last_tgt_lang': 'zh',
}
def _load_config():
cfg = _default_config()
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
cfg.update(json.load(f))
except Exception:
pass
return cfg
def _save_config(cfg):
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
# ==================== 字幕解析 ====================
def _parse_srt(text):
blocks = re.split(r'\n\s*\n', text.strip())
entries = []
for block in blocks:
lines = block.strip().split('\n')
if len(lines) < 3:
continue
try:
idx = int(lines[0].strip())
except ValueError:
continue
timecode = lines[1].strip()
content = '\n'.join(lines[2:]).strip()
if content:
entries.append((idx, timecode, content))
return entries
def _build_srt(entries):
parts = []
for idx, tc, content in entries:
parts.append(f'{idx}\n{tc}\n{content}')
return '\n\n'.join(parts) + '\n'
def _parse_vtt(text):
text = re.sub(r'^WEBVTT\s*\n', '', text.strip(), count=1)
blocks = re.split(r'\n\s*\n', text.strip())
entries = []
for i, block in enumerate(blocks):
lines = block.strip().split('\n')
if len(lines) < 2:
continue
timecode = None
content_lines = []
for line in lines:
if '-->' in line:
timecode = line.strip()
elif timecode is not None:
content_lines.append(line)
else:
if '-->' not in line:
continue
if timecode and content_lines:
entries.append((i + 1, timecode, '\n'.join(content_lines).strip()))
return entries
def _build_vtt(entries):
parts = ['WEBVTT\n']
for idx, tc, content in entries:
parts.append(f'{idx}\n{tc}\n{content}')
return '\n\n'.join(parts) + '\n'
def _parse_ass(text):
entries = []
for line in text.split('\n'):
line = line.strip()
if line.lower().startswith('dialogue:'):
entries.append(line)
return entries
def _ass_extract_text(dialogue_line):
parts = dialogue_line.split(',', 9)
if len(parts) < 10:
return dialogue_line, ''
text = parts[9]
text = re.sub(r'\{[^}]*\}', '', text)
text = text.replace('\\N', '\n').replace('\\n', '\n')
return dialogue_line, text.strip()
def _ass_replace_text(dialogue_line, new_text):
parts = dialogue_line.split(',', 9)
if len(parts) < 10:
return dialogue_line
translated = new_text.replace('\n', '\\N')
parts[9] = translated
return ','.join(parts)
def _build_ass(original_lines, translated_texts):
result = []
trans_idx = 0
for line in original_lines:
if line.lower().startswith('dialogue:'):
if trans_idx < len(translated_texts):
result.append(_ass_replace_text(line, translated_texts[trans_idx]))
trans_idx += 1
else:
result.append(line)
else:
result.append(line)
return '\n'.join(result)
def _detect_subtitle_format(filename, content):
ext = os.path.splitext(filename)[1].lower()
if ext == '.srt':
return 'srt'
if ext == '.vtt':
return 'vtt'
if ext in ('.ass', '.ssa'):
return 'ass'
if 'WEBVTT' in content[:20]:
return 'vtt'
if 'Dialogue:' in content:
return 'ass'
return 'srt'
# ==================== 模型加载与翻译 ====================
def _load_model(model_path, model_name):
global _model
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
import torch
_engine_status['loading'] = True
_engine_status['error'] = None
try:
device = 'cuda' if torch.cuda.is_available() else 'cpu'
tokenizer = AutoTokenizer.from_pretrained(model_path, clean_up_tokenization_spaces=True)
model = AutoModelForSeq2SeqLM.from_pretrained(model_path)
model = model.to(device)
model.eval()
_model = {'model': model, 'tokenizer': tokenizer, 'device': device}
_engine_status['loaded'] = True
_engine_status['model_name'] = model_name
_engine_status['engine_type'] = 'nllb'
_engine_status['error'] = None
return True, None
except Exception as e:
_engine_status['error'] = str(e)
return False, str(e)
finally:
_engine_status['loading'] = False
def _unload_model():
global _model
_model = None
_engine_status['loaded'] = False
_engine_status['model_name'] = None
_engine_status['engine_type'] = None
import gc
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
def _detect_lang(text):
"""用 langdetect 检测语言,返回我们的语言代码(如 'zh''en'"""
try:
from langdetect import detect
code = detect(text)
return _DETECT_LANG_MAP.get(code, _DETECT_LANG_MAP.get(code.split('-')[0], 'en'))
except Exception:
return 'en'
def _do_translate(texts, src_lang, tgt_lang, cfg):
if not _model:
return None, '模型未加载'
try:
import torch
model = _model['model']
tokenizer = _model['tokenizer']
device = _model['device']
nllb_tgt = _NLLB_LANG_MAP.get(tgt_lang, _NLLB_LANG_MAP.get('zh', 'zho_Hans'))
tgt_token_id = tokenizer.convert_tokens_to_ids(nllb_tgt)
# 自动检测时,用第一条文本检测语言
if src_lang == 'auto':
sample = ' '.join(texts[:3])
detected = _detect_lang(sample)
nllb_src = _NLLB_LANG_MAP.get(detected, 'eng_Latn')
else:
nllb_src = _NLLB_LANG_MAP.get(src_lang, 'eng_Latn')
results = []
for text in texts:
tokenizer.src_lang = nllb_src
inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512).to(device)
with torch.no_grad():
translated = model.generate(
**inputs,
forced_bos_token_id=tgt_token_id,
max_new_tokens=cfg.get('max_new_tokens', 512)
)
result = tokenizer.decode(translated[0], skip_special_tokens=True)
results.append(result)
return results, None
except Exception as e:
return None, str(e)
def _ensure_model(src_lang=None, tgt_lang=None):
if _engine_status['loaded'] and _model:
return True, None
cfg = _load_config()
models_dir = cfg.get('nllb_dir', '')
if not models_dir or not os.path.isdir(models_dir):
return False, '请在设置中配置 NLLB 模型目录'
# 用 last 模型或第一个可用
model_name = cfg.get('last_nllb_model', '')
if model_name:
full = os.path.join(models_dir, model_name)
if not (os.path.isdir(full) and os.path.exists(os.path.join(full, 'config.json'))):
model_name = ''
if not model_name:
for name in sorted(os.listdir(models_dir)):
full = os.path.join(models_dir, name)
if os.path.isdir(full) and os.path.exists(os.path.join(full, 'config.json')):
model_name = name
break
if not model_name:
return False, '未找到 NLLB 模型,请在设置中配置目录'
model_path = os.path.join(models_dir, model_name)
with _model_lock:
if _engine_status['loaded'] and _model:
return True, None
ok, load_err = _load_model(model_path, model_name)
if ok:
cfg['last_nllb_model'] = model_name
_save_config(cfg)
return ok, load_err
# ==================== 启动时自动加载模型 ====================
_auto_load_done = False
def _trigger_auto_load():
global _auto_load_done
if _auto_load_done:
return
_auto_load_done = True
threading.Thread(target=_ensure_model, daemon=True).start()
# ==================== 路由 ====================
@bp.route('/')
def page():
_trigger_auto_load()
cfg = _load_config()
return render_template('ai_translate.html', config=cfg, languages=LANGUAGES, engine_types=ENGINE_TYPES)
@bp.route('/model-status')
def model_status():
return jsonify({
'loaded': _engine_status['loaded'],
'loading': _engine_status['loading'],
'model_name': _engine_status.get('model_name'),
})
@bp.route('/config', methods=['GET'])
def get_config():
return jsonify(_load_config())
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
for key in ('nllb_dir', 'last_src_lang', 'last_tgt_lang', 'max_new_tokens', 'batch_size'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/models')
def list_models():
cfg = _load_config()
models_dir = cfg.get('nllb_dir', '')
models = []
if models_dir and os.path.isdir(models_dir):
for name in sorted(os.listdir(models_dir)):
full = os.path.join(models_dir, name)
if os.path.isdir(full) and os.path.exists(os.path.join(full, 'config.json')):
models.append({'name': name, 'type': 'huggingface', 'path': full})
return jsonify({'models': models, 'models_dir': models_dir, 'last_model': cfg.get('last_nllb_model', '')})
@bp.route('/languages')
def list_languages():
langs = [{'code': c, 'name': n} for c, n in LANGUAGES.items() if c != 'auto']
return jsonify({'src_langs': [{'code': 'auto', 'name': '自动检测'}] + langs, 'tgt_langs': langs})
@bp.route('/unload-model', methods=['POST'])
def unload_model():
with _model_lock:
_unload_model()
cfg = _load_config()
cfg['last_nllb_model'] = ''
_save_config(cfg)
return jsonify({'success': True, 'message': '模型已卸载'})
@bp.route('/load-model', methods=['POST'])
def load_model():
data = request.get_json() or {}
model_name = data.get('model_name', '').strip()
if not model_name:
return jsonify({'success': False, 'error': '请指定模型名称'}), 400
cfg = _load_config()
models_dir = cfg.get('nllb_dir', '')
if not models_dir or not os.path.isdir(models_dir):
return jsonify({'success': False, 'error': '请先配置 NLLB 模型目录'}), 400
model_path = os.path.join(models_dir, model_name)
if not os.path.isdir(model_path):
return jsonify({'success': False, 'error': f'模型目录不存在: {model_name}'}), 400
with _model_lock:
_unload_model()
ok, err = _load_model(model_path, model_name)
if ok:
cfg['last_nllb_model'] = model_name
_save_config(cfg)
return jsonify({'success': True, 'model_name': model_name})
return jsonify({'success': False, 'error': err or '加载失败'}), 400
@bp.route('/translate-text', methods=['POST'])
def translate_text():
data = request.get_json()
text = (data.get('text') or '').strip()
if not text:
return jsonify({'success': False, 'error': '请输入要翻译的文本'}), 400
src_lang = data.get('src_lang', 'en')
tgt_lang = data.get('tgt_lang', 'zh')
cfg = _load_config()
batch_size = cfg.get('batch_size', 8)
cfg['last_src_lang'] = src_lang
cfg['last_tgt_lang'] = tgt_lang
_save_config(cfg)
ok, err = _ensure_model(src_lang, tgt_lang)
if not ok:
return jsonify({'success': False, 'error': err}), 400
task_id = uuid.uuid4().hex
_tasks[task_id] = {'status': 'translating', 'progress': 0, 'result': None, 'error': None}
def _do_task():
try:
paragraphs = [p.strip() for p in text.split('\n') if p.strip()]
if not paragraphs:
paragraphs = [text]
all_results = []
total = len(paragraphs)
for i in range(0, total, batch_size):
batch = paragraphs[i:i + batch_size]
results, err = _do_translate(batch, src_lang, tgt_lang, cfg)
if err:
_tasks[task_id] = {'status': 'error', 'error': err}
return
all_results.extend(results)
_tasks[task_id]['progress'] = round((i + len(batch)) / total * 100)
_tasks[task_id] = {'status': 'done', 'result': '\n'.join(all_results), 'progress': 100}
except Exception as e:
_tasks[task_id] = {'status': 'error', 'error': str(e)}
threading.Thread(target=_do_task, daemon=True).start()
return jsonify({'success': True, 'task_id': task_id})
@bp.route('/translate-file', methods=['POST'])
def translate_file():
if 'subtitle' not in request.files:
return jsonify({'success': False, 'error': '请上传字幕文件'}), 400
f = request.files['subtitle']
if not f.filename:
return jsonify({'success': False, 'error': '请上传字幕文件'}), 400
ext = os.path.splitext(f.filename)[1].lower()
if ext not in SUBTITLE_EXTS:
return jsonify({'success': False, 'error': f'不支持的格式: {ext},支持 SRT/ASS/VTT'}), 400
src_lang = request.form.get('src_lang', 'en')
tgt_lang = request.form.get('tgt_lang', 'zh')
cfg = _load_config()
batch_size = cfg.get('batch_size', 8)
cfg['last_src_lang'] = src_lang
cfg['last_tgt_lang'] = tgt_lang
_save_config(cfg)
ok, err = _ensure_model(src_lang, tgt_lang)
if not ok:
return jsonify({'success': False, 'error': err}), 400
content = f.read().decode('utf-8', errors='replace')
fmt = _detect_subtitle_format(f.filename, content)
task_id = uuid.uuid4().hex
_tasks[task_id] = {'status': 'translating', 'progress': 0, 'result': None, 'error': None,
'filename': f.filename, 'format': fmt}
def _do_task():
try:
if fmt == 'srt':
entries = _parse_srt(content)
texts = [e[2] for e in entries]
elif fmt == 'vtt':
entries = _parse_vtt(content)
texts = [e[2] for e in entries]
elif fmt == 'ass':
full_lines = content.split('\n')
dialogue_lines = [l for l in full_lines if l.strip().lower().startswith('dialogue:')]
texts = []
for dl in dialogue_lines:
_, txt = _ass_extract_text(dl)
texts.append(txt if txt else '')
entries = dialogue_lines
else:
_tasks[task_id] = {'status': 'error', 'error': '未知字幕格式'}
return
if not texts:
_tasks[task_id] = {'status': 'error', 'error': '字幕文件中未找到可翻译的文本'}
return
all_results = []
total = len(texts)
for i in range(0, total, batch_size):
batch = texts[i:i + batch_size]
batch = [t for t in batch if t.strip()]
if not batch:
all_results.extend(texts[i:i + batch_size])
_tasks[task_id]['progress'] = round((i + batch_size) / total * 100)
continue
results, err = _do_translate(batch, src_lang, tgt_lang, cfg)
if err:
_tasks[task_id] = {'status': 'error', 'error': err}
return
ri = 0
for j in range(i, min(i + batch_size, total)):
if texts[j].strip():
all_results.append(results[ri] if ri < len(results) else texts[j])
ri += 1
else:
all_results.append('')
_tasks[task_id]['progress'] = round(min(i + batch_size, total) / total * 100)
if fmt == 'srt':
translated_entries = [(entries[i][0], entries[i][1], all_results[i]) for i in range(len(entries))]
output = _build_srt(translated_entries)
elif fmt == 'vtt':
translated_entries = [(entries[i][0], entries[i][1], all_results[i]) for i in range(len(entries))]
output = _build_vtt(translated_entries)
elif fmt == 'ass':
output = _build_ass(content.split('\n'), all_results)
else:
output = '\n'.join(all_results)
out_path = os.path.join(tempfile.gettempdir(), f'ai_trans_{task_id}{ext}')
with open(out_path, 'w', encoding='utf-8') as fout:
fout.write(output)
_tasks[task_id] = {
'status': 'done', 'progress': 100,
'result': output, 'file_path': out_path,
'filename': f.filename, 'format': fmt,
}
except Exception as e:
_tasks[task_id] = {'status': 'error', 'error': str(e)}
threading.Thread(target=_do_task, daemon=True).start()
return jsonify({'success': True, 'task_id': task_id})
@bp.route('/task-status/<task_id>')
def task_status(task_id):
task = _tasks.get(task_id)
if not task:
return jsonify({'success': False, 'error': '任务不存在'}), 404
return jsonify({'success': True, **task})
@bp.route('/download/<task_id>')
def download(task_id):
task = _tasks.get(task_id)
if not task or task.get('status') != 'done':
return jsonify({'error': '文件不存在'}), 404
file_path = task.get('file_path')
if not file_path or not os.path.exists(file_path):
return jsonify({'error': '文件不存在'}), 404
orig = task.get('filename', 'translated')
base, ext = os.path.splitext(orig)
return send_file(file_path, as_attachment=True, download_name=f'{base}_translated{ext}')
@bp.route('/cleanup/<task_id>', methods=['POST'])
def cleanup(task_id):
task = _tasks.pop(task_id, None)
if task and task.get('file_path'):
try:
os.remove(task['file_path'])
except OSError:
pass
return jsonify({'success': True})

View File

@@ -156,6 +156,93 @@ def analyze():
}) })
@bp.route('/manual-slice', methods=['POST'])
def manual_slice():
data = request.get_json()
task_id = data.get('task_id')
task = _tasks.get(task_id)
if not task:
return jsonify({'success': False, 'error': '任务不存在'}), 404
cut_points = sorted(data.get('cut_points', []))
sr = task['sr']
total = task['total']
duration = task['duration']
valid = [p for p in cut_points if 0 < p < duration]
if not valid:
return jsonify({'success': False, 'error': '没有有效的切割点'}), 400
samples = [int(round(p * sr)) for p in valid]
boundaries = [0] + samples + [total]
ranges = [(boundaries[i], boundaries[i + 1])
for i in range(len(boundaries) - 1)
if boundaries[i + 1] > boundaries[i]]
task['ranges'] = ranges
task['status'] = 'analyzed'
preview = [{'index': i, 'duration': round((e - b) / sr, 2), 'samples': e - b}
for i, (b, e) in enumerate(ranges)]
return jsonify({'success': True, 'count': len(ranges), 'preview': preview,
'sample_rate': int(sr), 'channels': int(task['ch'])})
@bp.route('/preview-range/<task_id>')
def preview_range(task_id):
task = _tasks.get(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
try:
start = float(request.args.get('start', 0))
end = float(request.args.get('end', task['duration']))
except (ValueError, TypeError):
return jsonify({'error': '参数错误'}), 400
sr = task['sr']
ch = task['ch']
begin = max(0, int(start * sr))
end_sample = min(task['total'], int(end * sr))
if end_sample <= begin:
return jsonify({'error': '无效范围'}), 400
buf = io.BytesIO()
with soundfile.SoundFile(task['src']) as src:
src.seek(begin)
frames = end_sample - begin
data = src.read(frames)
with soundfile.SoundFile(buf, mode='w', samplerate=sr, channels=ch,
format='WAV') as dst:
dst.write(data)
buf.seek(0)
return send_file(buf, mimetype='audio/wav')
@bp.route('/waveform-peaks/<task_id>')
def waveform_peaks(task_id):
"""返回波形峰值数据,供前端绘制波形图"""
task = _tasks.get(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
num_samples = int(request.args.get('samples', 400))
with soundfile.SoundFile(task['src']) as f:
sr = f.samplerate
ch = f.channels
total = len(f)
samples_per_bucket = max(1, total // num_samples)
peaks = []
for i in range(num_samples):
start = i * samples_per_bucket
length = min(samples_per_bucket, total - start)
if length <= 0:
break
data = f.read(length)
if ch > 1:
data = data.mean(axis=1)
peaks.append(float(abs(data).max()))
return jsonify({'peaks': peaks, 'duration': task['duration'], 'sr': sr})
@bp.route('/slice', methods=['POST']) @bp.route('/slice', methods=['POST'])
def start_slice(): def start_slice():
data = request.get_json() data = request.get_json()
@@ -166,6 +253,16 @@ def start_slice():
if not task.get('ranges'): if not task.get('ranges'):
return jsonify({'success': False, 'error': '请先分析'}), 400 return jsonify({'success': False, 'error': '请先分析'}), 400
# 支持选择性切割
selected = data.get('selected_indices')
all_ranges = task['ranges']
if selected is not None and isinstance(selected, list) and len(selected) > 0:
ranges = [all_ranges[i] for i in selected if 0 <= i < len(all_ranges)]
else:
ranges = all_ranges
if not ranges:
return jsonify({'success': False, 'error': '未选择任何片段'}), 400
task['status'] = 'slicing' task['status'] = 'slicing'
task['progress'] = 0 task['progress'] = 0
task['slices'] = [] task['slices'] = []
@@ -176,7 +273,6 @@ def start_slice():
def _do_slice(): def _do_slice():
base = os.path.splitext(task['orig_name'])[0] base = os.path.splitext(task['orig_name'])[0]
ranges = task['ranges']
total = len(ranges) total = len(ranges)
for i, (begin, end) in enumerate(ranges): for i, (begin, end) in enumerate(ranges):
out_path = os.path.join(out_dir, f'{base}_{i:03d}.wav') out_path = os.path.join(out_dir, f'{base}_{i:03d}.wav')

View File

@@ -0,0 +1,231 @@
# -*- coding: utf-8 -*-
"""
mp4-to-audio MP4转音频蓝图
基于 ffmpeg 提取视频中的音频流
"""
import os
import re
import json
import uuid
import subprocess
import threading
import tempfile
from flask import Blueprint, render_template, request, jsonify, send_file, after_this_request
try:
from config import BASE_DIR
except ImportError:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
bp = Blueprint('mp4_to_audio', __name__, url_prefix='/mp4-to-audio')
VIDEO_EXTS = ('.mp4', '.mkv', '.avi', '.webm', '.mov', '.flv', '.wmv')
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'mp4_to_audio_config.json')
DEFAULT_PARAMS = {
'output_format': 'mp3',
'mp3_bitrate': '320k',
'wav_sample_rate': 'original',
}
_tasks = {}
def _load_config():
cfg = dict(DEFAULT_PARAMS)
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
cfg.update(json.load(f))
except Exception:
pass
return cfg
def _save_config(cfg):
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _check_ffmpeg():
try:
r = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, timeout=5)
return r.returncode == 0
except Exception:
return False
def _get_duration(path):
try:
cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', path]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return float(r.stdout.strip())
except Exception:
return 0.0
def _do_convert(task_id):
task = _tasks[task_id]
src = task['src']
output_format = task['output_format']
out_ext = '.mp3' if output_format == 'mp3' else '.wav'
base = os.path.splitext(task['orig_name'])[0]
out_filename = f'{base}{out_ext}'
out_path = os.path.join(tempfile.gettempdir(), f'mp4audio_{task_id}{out_ext}')
cmd = ['ffmpeg', '-y', '-i', src]
if output_format == 'mp3':
cmd += ['-vn', '-acodec', 'libmp3lame', '-b:a', task['mp3_bitrate']]
else:
cmd += ['-vn', '-acodec', 'pcm_s16le']
if task['wav_sample_rate'] != 'original':
cmd += ['-ar', task['wav_sample_rate']]
cmd.append(out_path)
duration = task['duration']
try:
proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, universal_newlines=True)
for line in proc.stderr:
m = re.search(r'time=(\d+):(\d+):(\d+\.?\d*)', line)
if m and duration > 0:
cur = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3))
task['progress'] = min(99, int(cur / duration * 100))
proc.wait()
if proc.returncode != 0:
task['status'] = 'error'
task['error'] = 'ffmpeg 转换失败'
return
except Exception as e:
task['status'] = 'error'
task['error'] = str(e)
return
task['output_path'] = out_path
task['filename'] = out_filename
task['status'] = 'done'
task['progress'] = 100
@bp.route('/')
def page():
cfg = _load_config()
ffmpeg_ok = _check_ffmpeg()
return render_template('mp4_to_audio.html', config=cfg, ffmpeg_ok=ffmpeg_ok)
@bp.route('/config', methods=['GET'])
def get_config():
return jsonify(_load_config())
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
for key in DEFAULT_PARAMS:
if key in data:
cfg[key] = data[key]
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/upload', methods=['POST'])
def upload():
if 'video' not in request.files:
return jsonify({'success': False, 'error': '请上传视频文件'}), 400
f = request.files['video']
if not f.filename:
return jsonify({'success': False, 'error': '请上传视频文件'}), 400
ext = os.path.splitext(f.filename)[1].lower()
if ext not in VIDEO_EXTS:
return jsonify({'success': False, 'error': f'不支持的格式: {ext}'}), 400
task_id = uuid.uuid4().hex
save_path = os.path.join(tempfile.gettempdir(), f'mp4audio_{task_id}{ext}')
f.save(save_path)
duration = _get_duration(save_path)
size = os.path.getsize(save_path)
_tasks[task_id] = {
'src': save_path, 'orig_name': f.filename,
'output_path': None, 'output_format': 'mp3',
'duration': duration, 'size': size,
'status': 'uploaded', 'progress': 0, 'error': None,
'filename': None, 'mp3_bitrate': '320k', 'wav_sample_rate': 'original',
}
return jsonify({
'success': True, 'task_id': task_id,
'filename': f.filename, 'duration': round(duration, 2), 'size': size,
})
@bp.route('/convert', methods=['POST'])
def start_convert():
data = request.get_json()
task_id = data.get('task_id')
task = _tasks.get(task_id)
if not task:
return jsonify({'success': False, 'error': '任务不存在'}), 404
task['output_format'] = data.get('output_format', 'mp3')
task['mp3_bitrate'] = data.get('mp3_bitrate', '320k')
task['wav_sample_rate'] = data.get('wav_sample_rate', 'original')
task['status'] = 'converting'
task['progress'] = 0
task['error'] = None
threading.Thread(target=_do_convert, args=(task_id,), daemon=True).start()
return jsonify({'success': True})
@bp.route('/status/<task_id>')
def status(task_id):
task = _tasks.get(task_id)
if not task:
return jsonify({'success': False, 'error': '任务不存在'}), 404
resp = {
'success': True, 'status': task['status'], 'progress': task['progress'],
}
if task['status'] == 'done':
resp['filename'] = task['filename']
resp['output_format'] = task['output_format']
elif task['status'] == 'error':
resp['error'] = task.get('error', '未知错误')
return jsonify(resp)
@bp.route('/download/<task_id>')
def download(task_id):
task = _tasks.get(task_id)
if not task or not task.get('output_path') or not os.path.exists(task['output_path']):
return jsonify({'error': '文件不存在'}), 404
mime = 'audio/mpeg' if task['output_format'] == 'mp3' else 'audio/wav'
return send_file(task['output_path'], mimetype=mime, as_attachment=True,
download_name=task['filename'])
@bp.route('/play/<task_id>')
def play(task_id):
task = _tasks.get(task_id)
if not task or not task.get('output_path') or not os.path.exists(task['output_path']):
return jsonify({'error': '文件不存在'}), 404
mime = 'audio/mpeg' if task['output_format'] == 'mp3' else 'audio/wav'
return send_file(task['output_path'], mimetype=mime)
@bp.route('/cleanup/<task_id>', methods=['POST'])
def cleanup(task_id):
task = _tasks.pop(task_id, None)
if not task:
return jsonify({'success': True})
for key in ('src', 'output_path'):
try:
p = task.get(key)
if p and os.path.exists(p):
os.remove(p)
except OSError:
pass
return jsonify({'success': True})

View File

@@ -40,6 +40,7 @@ _tasks = {}
DEFAULT_PARAMS = { DEFAULT_PARAMS = {
'uvr_project_path': '', 'model_dir_mode': 'absolute', 'uvr_project_path': '', 'model_dir_mode': 'absolute',
'demucs_model_dir': '', 'vr_model_dir': '', 'mdx_model_dir': '', 'demucs_model_dir': '', 'vr_model_dir': '', 'mdx_model_dir': '',
'demucs_model_dir_mode': 'absolute', 'vr_model_dir_mode': 'absolute', 'mdx_model_dir_mode': 'absolute',
'arch_type': 'Demucs', 'arch_type': 'Demucs',
'save_format': 'wav', 'wav_type': 'PCM_16', 'mp3_bitrate': '320k', 'save_format': 'wav', 'wav_type': 'PCM_16', 'mp3_bitrate': '320k',
'is_gpu': True, 'device_set': 'Default', 'is_gpu': True, 'device_set': 'Default',
@@ -70,7 +71,7 @@ def _save_config(cfg):
json.dump(cfg, f, ensure_ascii=False, indent=2) json.dump(cfg, f, ensure_ascii=False, indent=2)
def _resolve_model_dir(raw_dir, cfg=None): def _resolve_model_dir(raw_dir, cfg=None, mode=None):
"""将模型目录路径解析为绝对路径""" """将模型目录路径解析为绝对路径"""
if not raw_dir: if not raw_dir:
return '' return ''
@@ -79,7 +80,8 @@ def _resolve_model_dir(raw_dir, cfg=None):
if cfg is None: if cfg is None:
cfg = _load_config() cfg = _load_config()
uvr_path = cfg.get('uvr_project_path', '') uvr_path = cfg.get('uvr_project_path', '')
mode = cfg.get('model_dir_mode', 'absolute') if mode is None:
mode = cfg.get('model_dir_mode', 'absolute')
if mode == 'relative' and uvr_path: if mode == 'relative' and uvr_path:
return os.path.normpath(os.path.join(uvr_path, raw_dir)) return os.path.normpath(os.path.join(uvr_path, raw_dir))
# absolute 模式下如果输入的是相对路径,也尝试拼接 uvr_project_path # absolute 模式下如果输入的是相对路径,也尝试拼接 uvr_project_path
@@ -92,13 +94,15 @@ def _resolve_model_dir(raw_dir, cfg=None):
def _get_model_dir_for_arch(arch_type, explicit_dir=None, cfg=None): def _get_model_dir_for_arch(arch_type, explicit_dir=None, cfg=None):
"""获取指定架构的模型目录:优先显式路径,否则从配置自动推断""" """获取指定架构的模型目录:优先显式路径,否则从配置自动推断"""
if explicit_dir:
return _resolve_model_dir(explicit_dir, cfg)
if cfg is None: if cfg is None:
cfg = _load_config() cfg = _load_config()
mode_map = {'Demucs': 'demucs_model_dir_mode', 'VR Arc': 'vr_model_dir_mode', 'MDX-Net': 'mdx_model_dir_mode'}
mode = cfg.get(mode_map.get(arch_type, ''), cfg.get('model_dir_mode', 'absolute'))
if explicit_dir:
return _resolve_model_dir(explicit_dir, cfg, mode=mode)
key_map = {'Demucs': 'demucs_model_dir', 'VR Arc': 'vr_model_dir', 'MDX-Net': 'mdx_model_dir'} key_map = {'Demucs': 'demucs_model_dir', 'VR Arc': 'vr_model_dir', 'MDX-Net': 'mdx_model_dir'}
raw = cfg.get(key_map.get(arch_type, ''), '') raw = cfg.get(key_map.get(arch_type, ''), '')
return _resolve_model_dir(raw, cfg) return _resolve_model_dir(raw, cfg, mode=mode)
def _get_uvr_paths(): def _get_uvr_paths():
@@ -639,18 +643,47 @@ def _update_progress(task_id, step, inference_iterations=0):
return return
progress = min(99, max(1, int((step + inference_iterations) * 100))) progress = min(99, max(1, int((step + inference_iterations) * 100)))
task['progress'] = progress task['progress'] = progress
import time as _t
# 记录子进度inference_iterations > 0 表示推理中的迭代进度)
if inference_iterations > 0:
last = task.get('_last_iter_log', 0)
if inference_iterations - last >= 0.1:
task['_last_iter_log'] = inference_iterations
task['logs'].append('[%s] Inference iteration: %.0f%%' % (_t.strftime('%H:%M:%S'), inference_iterations * 100))
if len(task['logs']) > 500:
task['logs'] = task['logs'][-500:]
else:
# 主进度每 20% 记录一条
last = task.get('_last_progress_log', 0)
if progress - last >= 20:
task['_last_progress_log'] = progress
task['logs'].append('[%s] Progress: %d%%' % (_t.strftime('%H:%M:%S'), progress))
if len(task['logs']) > 500:
task['logs'] = task['logs'][-500:]
def _make_process_data(task_id, model_data, audio_path, export_path): def _make_process_data(task_id, model_data, audio_path, export_path):
"""构造 process_data 字典""" """构造 process_data 字典"""
base = os.path.splitext(os.path.basename(audio_path))[0] base = os.path.splitext(os.path.basename(audio_path))[0]
task = _tasks.get(task_id)
def _write_console(*args, **kwargs):
msg = ' '.join(str(a) for a in args)
msg = msg.replace('\r\n', '\n').replace('\r', '\n').strip()
if msg and task:
import time as _t
for line in msg.split('\n'):
line = line.strip()
if line:
task['logs'].append('[%s] %s' % (_t.strftime('%H:%M:%S'), line))
if len(task['logs']) > 500:
task['logs'] = task['logs'][-500:]
return { return {
'model_data': model_data, 'model_data': model_data,
'export_path': export_path, 'export_path': export_path,
'audio_file_base': base, 'audio_file_base': base,
'audio_file': audio_path, 'audio_file': audio_path,
'set_progress_bar': lambda step, it=0: _update_progress(task_id, step, it), 'set_progress_bar': lambda step, it=0: _update_progress(task_id, step, it),
'write_to_console': lambda *_, **__: None, 'write_to_console': _write_console,
'process_iteration': lambda: None, 'process_iteration': lambda: None,
'cached_source_callback': lambda *_, **__: (None, None), 'cached_source_callback': lambda *_, **__: (None, None),
'cached_model_source_holder': lambda *_, **__: None, 'cached_model_source_holder': lambda *_, **__: None,
@@ -663,6 +696,29 @@ def _make_process_data(task_id, model_data, audio_path, export_path):
def _do_separate(task_id, model_path, process_method, audio_path, params, model_meta): def _do_separate(task_id, model_path, process_method, audio_path, params, model_meta):
"""后台线程执行分离""" """后台线程执行分离"""
task = _tasks[task_id] task = _tasks[task_id]
import time as _time
# 日志捕获
class _LogCapture:
def __init__(self, orig):
self._orig = orig
def write(self, msg):
if msg and msg.strip():
text = msg.replace('\r\n', '\n').replace('\r', '\n').strip()
for line in text.split('\n'):
line = line.strip()
if line:
task['logs'].append('[%s] %s' % (_time.strftime('%H:%M:%S'), line))
if len(task['logs']) > 500:
task['logs'] = task['logs'][-500:]
self._orig.write(msg)
def flush(self):
self._orig.flush()
old_stdout, old_stderr = sys.stdout, sys.stderr
sys.stdout = _LogCapture(old_stdout)
sys.stderr = _LogCapture(old_stderr)
try: try:
_ensure_uvr_imports() _ensure_uvr_imports()
@@ -727,6 +783,8 @@ def _do_separate(task_id, model_path, process_method, audio_path, params, model_
task['error'] = str(e) task['error'] = str(e)
traceback.print_exc() traceback.print_exc()
finally: finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
try: try:
os.remove(audio_path) os.remove(audio_path)
except OSError: except OSError:
@@ -945,6 +1003,7 @@ def start_separate():
'stems': [], 'stems': [],
'error': None, 'error': None,
'device': '', 'device': '',
'logs': [],
} }
threading.Thread( threading.Thread(
@@ -968,6 +1027,7 @@ def status(task_id):
} }
if task.get('device'): if task.get('device'):
resp['device'] = task['device'] resp['device'] = task['device']
resp['logs'] = task.get('logs', [])[-200:]
if task['status'] == 'done': if task['status'] == 'done':
resp['stems'] = task['stems'] resp['stems'] = task['stems']
resp['count'] = len(task['stems']) resp['count'] = len(task['stems'])
@@ -976,6 +1036,14 @@ def status(task_id):
return jsonify(resp) return jsonify(resp)
@bp.route('/clear-logs/<task_id>', methods=['POST'])
def clear_logs(task_id):
task = _tasks.get(task_id)
if task:
task['logs'] = []
return jsonify({'success': True})
@bp.route('/download/<task_id>/<stem>') @bp.route('/download/<task_id>/<stem>')
def download_stem(task_id, stem): def download_stem(task_id, stem):
task = _tasks.get(task_id) task = _tasks.get(task_id)

View File

@@ -0,0 +1,9 @@
{
"engine_type": "nllb",
"nllb_dir": "E:\\AI\\ai-translation\\nllb",
"max_new_tokens": 512,
"batch_size": 8,
"last_nllb_model": "nllb-200-distilled-1.3B",
"last_src_lang": "auto",
"last_tgt_lang": "zh"
}

View File

@@ -1,7 +1,7 @@
{ {
"threshold": -40, "threshold": -41,
"min_length": 5000, "min_length": 5000,
"min_interval": 100, "min_interval": 300,
"hop_size": 10, "hop_size": 10,
"max_sil_kept": 1000 "max_sil_kept": 1000
} }

View File

@@ -0,0 +1,11 @@
{
"hidden_features": [
"sovits-tts",
"json-format",
"http-status",
"url-parser",
"chmod-calc",
"token-gen",
"qr-code"
]
}

View File

@@ -1,9 +1,12 @@
{ {
"uvr_project_path": "E:\\AI\\ultimatevocalremovergui", "uvr_project_path": "E:\\AI\\ultimatevocalremovergui",
"model_dir_mode": "absolute", "model_dir_mode": "absolute",
"demucs_model_dir": "uvr5-model\\demucs_model", "demucs_model_dir": "E:\\AI\\uvr5-model\\demucs_model",
"vr_model_dir": "E:\\AI\\uvr5-model\\vr-arc", "vr_model_dir": "E:\\AI\\uvr5-model\\vr-arc",
"mdx_model_dir": "E:\\AI\\uvr5-model\\mdx-net", "mdx_model_dir": "E:\\AI\\uvr5-model\\mdx-net",
"demucs_model_dir_mode": "absolute",
"vr_model_dir_mode": "absolute",
"mdx_model_dir_mode": "absolute",
"arch_type": "Demucs", "arch_type": "Demucs",
"save_format": "wav", "save_format": "wav",
"wav_type": "PCM_16", "wav_type": "PCM_16",
@@ -21,7 +24,7 @@
"vr_window_size": 1024, "vr_window_size": 1024,
"vr_aggression": 5, "vr_aggression": 5,
"vr_batch_size": 4, "vr_batch_size": 4,
"demucs_selected_model": "", "demucs_selected_model": "htdemucs_ft_v4.yaml",
"mdx_selected_model": "", "mdx_selected_model": "",
"vr_selected_model": "" "vr_selected_model": ""
} }

View File

@@ -53,6 +53,7 @@
.fa-database:before { content: "\f1c0"; } .fa-database:before { content: "\f1c0"; }
.fa-file:before { content: "\f15b"; } .fa-file:before { content: "\f15b"; }
.fa-play:before { content: "\f04b"; } .fa-play:before { content: "\f04b"; }
.fa-pause:before { content: "\f04c"; }
.fa-pen:before { content: "\f303"; } .fa-pen:before { content: "\f303"; }
.fa-fire:before { content: "\f06d"; } .fa-fire:before { content: "\f06d"; }
.fa-tachometer-alt:before { content: "\f3fd"; } .fa-tachometer-alt:before { content: "\f3fd"; }
@@ -66,3 +67,13 @@
.fa-code:before { content: "\f121"; } .fa-code:before { content: "\f121"; }
.fa-key:before { content: "\f084"; } .fa-key:before { content: "\f084"; }
.fa-microphone:before { content: "\f130"; } .fa-microphone:before { content: "\f130"; }
.fa-gear:before,
.fa-cog:before { content: "\f013"; }
.fa-times:before { content: "\f00d"; }
.fa-eye:before { content: "\f06e"; }
.fa-floppy-disk:before { content: "\f0c7"; }
.fa-sliders:before { content: "\f1de"; }
.fa-language:before { content: "\f1ab"; }
.fa-globe:before { content: "\f0ac"; }
.fa-stop:before { content: "\f04d"; }
.fa-download:before { content: "\f019"; }

Binary file not shown.

View File

@@ -0,0 +1,664 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>AI 翻译</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; color: #333; height: 100vh; overflow: hidden; display: flex;
}
/* ===== 左侧面板 ===== */
.left-panel {
width: 460px; flex-shrink: 0; display: flex; flex-direction: column;
border-right: 1px solid #e8e8e8; background: #fff; height: 100vh; overflow-y: auto;
}
.left-panel::-webkit-scrollbar { width: 4px; }
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.left-body { padding: 20px 22px 28px; }
/* 标题 */
.page-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
}
.page-header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; }
.page-header h1 i { color: #0078d4; margin-right: 8px; font-size: 16px; }
.page-subtitle { color: #aaa; font-size: 12px; margin-bottom: 18px; }
/* 设置按钮 */
.icon-btn {
width: 30px; height: 30px; border: none; background: none;
border-radius: 8px; cursor: pointer; font-size: 14px; color: #bbb;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.icon-btn:hover { background: #f0f0f0; color: #666; }
/* 设置弹窗 */
.modal-overlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.35); z-index: 1000;
justify-content: center; align-items: flex-start; padding-top: 80px;
}
.modal-overlay.show { display: flex; }
.modal {
background: #fff; border-radius: 12px; width: 520px;
box-shadow: 0 12px 40px rgba(0,0,0,0.2); overflow: hidden;
}
.modal-head {
padding: 14px 18px; border-bottom: 1px solid #eee;
display: flex; align-items: center; justify-content: space-between;
}
.modal-head h3 { font-size: 14px; font-weight: 600; color: #333; }
.modal-head h3 i { margin-right: 6px; color: #999; }
.modal-close {
width: 26px; height: 26px; border: none; background: #f5f5f5;
border-radius: 6px; cursor: pointer; font-size: 13px; color: #999;
display: flex; align-items: center; justify-content: center;
}
.modal-close:hover { background: #eee; color: #333; }
.modal-body { padding: 16px 18px; }
.modal-foot {
padding: 10px 18px; border-top: 1px solid #eee;
display: flex; justify-content: flex-end; gap: 8px;
}
.modal-foot button {
padding: 6px 18px; font-size: 12px; font-weight: 600;
border-radius: 6px; cursor: pointer; transition: all 0.15s;
}
.btn-cancel { border: 1px solid #e0e0e0; background: #fff; color: #666; }
.btn-cancel:hover { background: #f5f5f5; }
.btn-save { border: none; background: #0078d4; color: #fff; }
.btn-save:hover { background: #006cbd; }
/* 卡片 */
.card {
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 10px;
padding: 14px 16px; margin-bottom: 10px;
}
.card-title {
font-size: 11px; font-weight: 600; color: #999; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 10px; display: flex; align-items: center; gap: 6px;
}
.card-title i { font-size: 11px; }
/* 模型列表 */
.model-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border: 1px solid #e8e8e8; border-radius: 6px;
margin-bottom: 5px; cursor: pointer; transition: all 0.15s;
font-size: 12px; color: #333; background: #fff;
}
.model-item:hover { border-color: #0078d4; background: #f0f7ff; }
.model-item.active { border-color: #0078d4; background: #e3f2fd; }
.model-item .model-name { flex: 1; font-weight: 500; }
.model-item .model-status {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.model-item .model-status.loaded { background: #27ae60; }
/* 表单 */
.field { margin-bottom: 8px; }
.field:last-child { margin-bottom: 0; }
.field label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 3px; color: #555; }
.field select, .field input[type="text"] {
width: 100%; padding: 7px 10px;
border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #333; font-size: 13px;
outline: none; transition: all 0.15s;
}
.field select:focus, .field input[type="text"]:focus {
border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
}
/* 语言行 */
.lang-row { display: flex; gap: 6px; align-items: flex-end; }
.lang-row .lang-col { flex: 1; }
.lang-row .lang-col label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555; }
.lang-row .lang-col select {
width: 100%; padding: 8px 10px;
border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #333; font-size: 13px;
outline: none; transition: all 0.15s;
}
.lang-row .lang-col select:focus {
border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
}
.lang-swap-btn {
width: 32px; height: 34px; border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #aaa; cursor: pointer; display: flex; align-items: center;
justify-content: center; font-size: 12px; flex-shrink: 0; transition: all 0.15s;
}
.lang-swap-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
/* textarea */
textarea {
width: 100%; min-height: 320px; padding: 10px; resize: vertical;
border: 1px solid #e0e0e0; border-radius: 6px; background: #fff;
color: #333; font-size: 13px; line-height: 1.6; outline: none;
transition: all 0.15s; font-family: inherit;
}
textarea:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
/* 上传区 */
.upload-area {
border: 2px dashed #e0e0e0; border-radius: 10px;
padding: 24px 16px; text-align: center; cursor: pointer;
transition: all 0.2s; background: #fff;
}
.upload-area:hover, .upload-area.dragover { border-color: #0078d4; background: #f0f7ff; }
.upload-area i { font-size: 24px; color: #ccc; margin-bottom: 6px; display: block; }
.upload-area .label { font-size: 13px; color: #999; }
.upload-area .filename { font-size: 13px; color: #0078d4; font-weight: 600; margin-top: 4px; }
.upload-area input[type="file"] { display: none; }
/* 模式切换 */
.mode-tabs {
display: flex; gap: 4px; background: #f0f0f0; border-radius: 8px;
padding: 3px; margin-bottom: 12px;
}
.mode-tab {
flex: 1; padding: 7px 0; text-align: center; font-size: 13px; font-weight: 500;
border-radius: 6px; cursor: pointer; color: #666; transition: all 0.15s;
border: none; background: transparent;
}
.mode-tab.active { background: #fff; color: #0078d4; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
/* 按钮 */
.btn {
padding: 7px 14px; border-radius: 7px; font-size: 12px; font-weight: 600;
cursor: pointer; transition: all 0.15s; border: none;
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
}
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; }
/* 主操作按钮 */
.action-btn {
width: 100%; padding: 10px 18px;
background: linear-gradient(135deg, #0078d4, #005fa3);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 8px;
transition: all 0.2s; margin-top: 4px;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.action-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #006cbd, #004e8a);
box-shadow: 0 4px 12px rgba(0,120,212,0.3); transform: translateY(-1px);
}
.action-btn:active:not(:disabled) { transform: translateY(0); }
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
/* ===== 右侧面板 ===== */
.right-panel {
flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0;
}
.right-header {
padding: 14px 20px; border-bottom: 1px solid #e8e8e8; background: #fff;
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
.right-header h2 { font-size: 14px; font-weight: 600; color: #333; }
.right-header h2 i { color: #0078d4; margin-right: 6px; }
.right-body { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* 进度 spinner */
.progress-wrap { padding: 16px 20px; flex-shrink: 0; display: none; align-items: center; gap: 12px; }
.progress-wrap.show { display: flex; }
.spinner {
width: 24px; height: 24px; flex-shrink: 0;
border: 3px solid #e0e0e0; border-top-color: #0078d4;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.progress-info { font-size: 13px; color: #555; }
.progress-info .pct { font-weight: 600; color: #0078d4; margin-left: 4px; }
/* 结果 */
.result-area { flex: 1; overflow-y: auto; padding: 16px 20px; background: #fff; }
.result-area::-webkit-scrollbar { width: 6px; }
.result-area::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
.result-placeholder { text-align: center; padding: 60px 20px; color: #ccc; }
.result-placeholder i { font-size: 48px; margin-bottom: 12px; display: block; }
.result-placeholder .text { font-size: 14px; }
.result-text { font-size: 14px; line-height: 1.8; color: #333; white-space: pre-wrap; word-break: break-all; }
/* Toast */
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: toastIn 0.3s ease;
max-width: 360px;
}
.toast.success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
.toast.error { background: #fce4ec; color: #c62828; border: 1px solid #f8bbd0; }
.toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
/* 全屏加载遮罩 */
.loading-overlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.45); z-index: 3000;
justify-content: center; align-items: center; flex-direction: column; gap: 16px;
}
.loading-overlay.show { display: flex; }
.loading-overlay .loading-spinner {
width: 40px; height: 40px;
border: 4px solid rgba(255,255,255,0.3); border-top-color: #fff;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
.loading-overlay .loading-text { color: #fff; font-size: 15px; font-weight: 500; }
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">正在加载模型,请稍候...</div>
</div>
<!-- ===== 左侧面板 ===== -->
<div class="left-panel">
<div class="left-body">
<!-- 标题 -->
<div class="page-header">
<h1><i class="fas fa-language"></i>AI 翻译</h1>
<button class="icon-btn" onclick="openSettings()" title="设置"><i class="fas fa-gear"></i></button>
</div>
<div class="page-subtitle">NLLB-200 本地翻译 · 点击齿轮配置模型目录</div>
<!-- 设置弹窗 -->
<div class="modal-overlay" id="settingsOverlay" onclick="closeSettings(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-head">
<h3><i class="fas fa-gear"></i>NLLB-200 模型配置</h3>
<button class="modal-close" onclick="closeSettings()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<div class="field">
<label>模型目录HF 目录或模型 ID</label>
<input type="text" id="dir_nllb" value="{{ config.nllb_dir }}" placeholder="E:\AI\ai-translation\nllb">
</div>
<div style="margin-top:10px;padding:8px 10px;background:#f0f7ff;border:1px solid #d0e3f7;border-radius:6px;font-size:12px;display:flex;align-items:center;gap:6px;">
<span style="color:#999;">当前模型:</span>
<span id="currentModelName" style="color:#0078d4;font-weight:600;">未加载</span>
</div>
<div style="margin-top:12px;">
<label style="display:block;font-size:12px;font-weight:500;margin-bottom:6px;color:#555;">选择模型</label>
<div id="modelListContent">
<div style="color:#999;font-size:12px;">加载中...</div>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn-cancel" onclick="unloadModel()" style="margin-right:auto;border:1px solid #e74c3c;color:#e74c3c;">卸载模型</button>
<button class="btn-cancel" onclick="closeSettings()">取消</button>
<button class="btn-save" onclick="saveSettings()">保存</button>
</div>
</div>
</div>
<!-- 模式切换 -->
<div class="mode-tabs">
<button class="mode-tab active" id="tabText" onclick="switchMode('text')">文本翻译</button>
<button class="mode-tab" id="tabFile" onclick="switchMode('file')">字幕翻译</button>
</div>
<!-- 语言设置 -->
<div class="card">
<div class="card-title" style="justify-content:space-between;">
<span><i class="fas fa-globe"></i> 语言设置</span>
<button onclick="saveLangs()" style="border:none;background:#0078d4;color:#fff;font-size:11px;padding:3px 10px;border-radius:4px;cursor:pointer;text-transform:none;letter-spacing:0;">保存</button>
</div>
<div class="lang-row">
<div class="lang-col">
<label>源语言</label>
<select id="srcLang">
<option value="auto" {% if config.last_src_lang == 'auto' %}selected{% endif %}>自动检测</option>
{% for code, name in languages.items() %}{% if code != 'auto' %}
<option value="{{ code }}" {% if code == config.last_src_lang %}selected{% endif %}>{{ name }}</option>
{% endif %}{% endfor %}
</select>
</div>
<button class="lang-swap-btn" onclick="swapLangs()" title="交换语言">
<i class="fas fa-exchange-alt"></i>
</button>
<div class="lang-col">
<label>目标语言</label>
<select id="tgtLang">
{% for code, name in languages.items() %}{% if code != 'auto' %}
<option value="{{ code }}" {% if code == config.last_tgt_lang %}selected{% endif %}>{{ name }}</option>
{% endif %}{% endfor %}
</select>
</div>
</div>
</div>
<!-- 文本翻译 -->
<div id="textSection">
<div class="card">
<div class="card-title"><i class="fas fa-pen"></i> 输入文本</div>
<div class="field">
<textarea id="inputText" placeholder="请输入要翻译的文本,每行一句或多段落..."></textarea>
</div>
</div>
<button class="action-btn" id="btnTranslateText" onclick="translateText()">
<i class="fas fa-language"></i> 开始翻译
</button>
</div>
<!-- 字幕翻译 -->
<div id="fileSection" style="display:none;">
<div class="card">
<div class="card-title"><i class="fas fa-file"></i> 上传字幕</div>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('subtitleFile').click()">
<i class="fas fa-file"></i>
<div class="label" id="uploadLabel">点击或拖拽上传字幕文件SRT / ASS / VTT</div>
<div class="filename" id="uploadFilename" style="display:none;"></div>
<input type="file" id="subtitleFile" accept=".srt,.ass,.ssa,.vtt">
</div>
</div>
<button class="action-btn" id="btnTranslateFile" onclick="translateFile()">
<i class="fas fa-language"></i> 翻译字幕
</button>
</div>
</div>
</div>
<!-- ===== 右侧面板 ===== -->
<div class="right-panel">
<div class="right-header">
<h2><i class="fas fa-language"></i>翻译结果</h2>
<button class="btn btn-outline" id="btnDownload" style="display:none;margin-left:auto;padding:5px 14px;font-size:12px;" onclick="downloadResult()">
<i class="fas fa-download"></i> 下载字幕
</button>
</div>
<div class="right-body">
<div class="progress-wrap" id="progressWrap">
<div class="spinner"></div>
<div class="progress-info">翻译中<span class="pct" id="progressText">0%</span></div>
</div>
<div class="result-area" id="resultArea">
<div class="result-placeholder">
<i class="fas fa-language"></i>
<div class="text">翻译结果将显示在这里</div>
</div>
</div>
</div>
</div>
<script>
var currentMode = 'text';
var currentTaskId = null;
// ===== Toast =====
function showMessage(text, type, duration) {
type = type || 'info'; duration = duration || 3000;
var t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = text;
document.getElementById('toastContainer').appendChild(t);
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function() { t.remove(); }, 300); }, duration);
}
// ===== 设置弹窗 =====
function openSettings() {
document.getElementById('settingsOverlay').classList.add('show');
scanModels();
refreshModelStatus();
}
function closeSettings(e) {
if (e && e.target !== document.getElementById('settingsOverlay')) return;
document.getElementById('settingsOverlay').classList.remove('show');
}
function refreshModelStatus() {
fetch('/ai-translate/model-status').then(function(r) { return r.json(); }).then(function(data) {
var el = document.getElementById('currentModelName');
if (data.loaded && data.model_name) {
el.textContent = data.model_name;
el.style.color = '#27ae60';
} else {
el.textContent = '未加载';
el.style.color = '#999';
}
});
}
function unloadModel() {
var overlay = document.getElementById('loadingOverlay');
document.getElementById('loadingText').textContent = '正在卸载模型...';
overlay.classList.add('show');
fetch('/ai-translate/unload-model', {method: 'POST'})
.then(function(r) { return r.json(); }).then(function(data) {
overlay.classList.remove('show');
document.getElementById('loadingText').textContent = '正在加载模型,请稍候...';
showMessage('模型已卸载', 'success');
refreshModelStatus();
scanModels();
}).catch(function() {
overlay.classList.remove('show');
document.getElementById('loadingText').textContent = '正在加载模型,请稍候...';
showMessage('卸载失败', 'error');
});
}
function saveSettings() {
fetch('/ai-translate/config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ nllb_dir: document.getElementById('dir_nllb').value.trim() })
}).then(function(r) { return r.json(); }).then(function() {
showMessage('目录配置已保存', 'success');
scanModels();
});
}
// ===== 模式切换 =====
function switchMode(mode) {
currentMode = mode;
document.getElementById('tabText').className = 'mode-tab' + (mode === 'text' ? ' active' : '');
document.getElementById('tabFile').className = 'mode-tab' + (mode === 'file' ? ' active' : '');
document.getElementById('textSection').style.display = mode === 'text' ? '' : 'none';
document.getElementById('fileSection').style.display = mode === 'file' ? '' : 'none';
document.getElementById('btnDownload').style.display = 'none';
}
// ===== 语言 =====
function swapLangs() {
var src = document.getElementById('srcLang'), tgt = document.getElementById('tgtLang');
if (src.value === 'auto') { showMessage('源语言为"自动检测"时无法交换', 'error'); return; }
var tmp = src.value; src.value = tgt.value; tgt.value = tmp;
}
function saveLangs() {
fetch('/ai-translate/config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
last_src_lang: document.getElementById('srcLang').value,
last_tgt_lang: document.getElementById('tgtLang').value
})
}).then(function(r) { return r.json(); }).then(function() {
showMessage('语言设置已保存', 'success');
});
}
// ===== 扫描模型 =====
function scanModels() {
var content = document.getElementById('modelListContent');
content.innerHTML = '<div style="color:#999;font-size:12px;">加载中...</div>';
fetch('/ai-translate/models')
.then(function(r) { return r.json(); })
.then(function(data) {
var models = data.models || [];
if (!models.length) {
content.innerHTML = '<div style="color:#e74c3c;font-size:12px;">未找到模型,请在设置中配置目录</div>';
return;
}
var lastModel = data.last_model || '';
var html = '';
models.forEach(function(m) {
var isActive = m.name === lastModel;
html += '<div class="model-item' + (isActive ? ' active' : '') + '" onclick="loadModel(\'' + m.name.replace(/'/g, "\\'") + '\')">';
html += '<span class="model-name">' + m.name + '</span>';
if (isActive) html += '<span class="model-status loaded"></span>';
html += '</div>';
});
content.innerHTML = html;
}).catch(function() {
content.innerHTML = '<div style="color:#e74c3c;font-size:12px;">加载失败,请刷新重试</div>';
});
}
// ===== 加载模型 =====
function loadModel(name) {
var overlay = document.getElementById('loadingOverlay');
overlay.classList.add('show');
fetch('/ai-translate/load-model', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model_name: name, engine_type: 'nllb'})
})
.then(function(r) { return r.json(); })
.then(function(data) {
overlay.classList.remove('show');
if (data.success) {
showMessage('模型加载成功: ' + name, 'success');
} else {
showMessage(data.error || '加载失败', 'error');
}
scanModels();
refreshModelStatus();
}).catch(function() {
overlay.classList.remove('show');
showMessage('加载失败', 'error');
});
}
// ===== 翻译 =====
function translateText() {
var text = document.getElementById('inputText').value.trim();
if (!text) { showMessage('请输入要翻译的文本', 'error'); return; }
document.getElementById('btnTranslateText').disabled = true;
showProgress(0);
fetch('/ai-translate/translate-text', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
text: text,
src_lang: document.getElementById('srcLang').value,
tgt_lang: document.getElementById('tgtLang').value
})
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.success) { currentTaskId = data.task_id; pollTask(); }
else { showMessage(data.error || '翻译失败', 'error'); resetBtns(); hideProgress(); }
}).catch(function() { resetBtns(); hideProgress(); });
}
function translateFile() {
var fi = document.getElementById('subtitleFile');
if (!fi.files.length) { showMessage('请上传字幕文件', 'error'); return; }
document.getElementById('btnTranslateFile').disabled = true;
showProgress(0);
var fd = new FormData();
fd.append('subtitle', fi.files[0]);
fd.append('src_lang', document.getElementById('srcLang').value);
fd.append('tgt_lang', document.getElementById('tgtLang').value);
fetch('/ai-translate/translate-file', {method: 'POST', body: fd})
.then(function(r) { return r.json(); }).then(function(data) {
if (data.success) { currentTaskId = data.task_id; pollTask(); }
else { showMessage(data.error || '翻译失败', 'error'); resetBtns(); hideProgress(); }
}).catch(function() { resetBtns(); hideProgress(); });
}
function pollTask() {
if (!currentTaskId) return;
fetch('/ai-translate/task-status/' + currentTaskId)
.then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) return;
if (data.progress !== undefined) showProgress(data.progress);
if (data.status === 'done') {
showResult(data.result || '');
if (data.file_path) document.getElementById('btnDownload').style.display = '';
showMessage('翻译完成', 'success');
resetBtns(); hideProgress(); currentTaskId = null;
} else if (data.status === 'error') {
showMessage('翻译失败: ' + (data.error || '未知错误'), 'error');
resetBtns(); hideProgress(); currentTaskId = null;
} else { setTimeout(pollTask, 500); }
});
}
function showResult(text) {
var el = document.getElementById('resultArea');
el.innerHTML = '<div class="result-text"></div>';
el.querySelector('.result-text').textContent = text;
}
function resetBtns() {
document.getElementById('btnTranslateText').disabled = false;
document.getElementById('btnTranslateFile').disabled = false;
}
function showProgress(p) {
document.getElementById('progressWrap').classList.add('show');
document.getElementById('progressText').textContent = p + '%';
}
function hideProgress() { document.getElementById('progressWrap').classList.remove('show'); }
// ===== 下载 =====
function downloadResult() {
if (currentTaskId) window.open('/ai-translate/download/' + currentTaskId, '_blank');
}
// ===== 文件上传 =====
var ua = document.getElementById('uploadArea');
var sf = document.getElementById('subtitleFile');
ua.addEventListener('dragover', function(e) { e.preventDefault(); ua.classList.add('dragover'); });
ua.addEventListener('dragleave', function() { ua.classList.remove('dragover'); });
ua.addEventListener('drop', function(e) {
e.preventDefault(); ua.classList.remove('dragover');
if (e.dataTransfer.files.length) { sf.files = e.dataTransfer.files; onFileSel(); }
});
sf.addEventListener('change', onFileSel);
function onFileSel() {
var f = sf.files[0];
if (f) {
document.getElementById('uploadLabel').style.display = 'none';
var fn = document.getElementById('uploadFilename');
fn.style.display = ''; fn.textContent = f.name;
}
}
// ===== 初始化 =====
(function() {
// 页面加载时检查模型是否正在加载中
fetch('/ai-translate/model-status').then(function(r) { return r.json(); }).then(function(data) {
if (data.loading) {
var overlay = document.getElementById('loadingOverlay');
overlay.classList.add('show');
// 轮询直到加载完成
var timer = setInterval(function() {
fetch('/ai-translate/model-status').then(function(r) { return r.json(); }).then(function(d) {
if (!d.loading) {
clearInterval(timer);
overlay.classList.remove('show');
if (d.loaded) {
showMessage('模型加载成功: ' + (d.model_name || ''), 'success');
}
}
});
}, 1000);
}
});
})();
</script>
</body>
</html>

View File

@@ -146,6 +146,13 @@
} }
.slice-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; } .slice-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
.slice-btn.play-btn:hover { color: #0078d4; border-color: #0078d4; } .slice-btn.play-btn:hover { color: #0078d4; border-color: #0078d4; }
.param-save-btn {
margin-left: auto; padding: 3px 10px; border: 1px solid #e0e0e0;
border-radius: 5px; background: #fff; color: #666; font-size: 11px;
font-weight: 600; cursor: pointer; transition: all 0.15s;
display: inline-flex; align-items: center; gap: 4px;
}
.param-save-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
/* 进度条 */ /* 进度条 */
.progress-bar { .progress-bar {
@@ -181,6 +188,27 @@
.status-text { font-size: 12px; color: #999; margin: 8px 0; } .status-text { font-size: 12px; color: #999; margin: 8px 0; }
/* 片段选择 */
.seg-row {
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
border-bottom: 1px solid #f0f0f0; cursor: pointer; border-radius: 4px;
transition: background 0.15s;
}
.seg-row:hover { background: #f5f5f5; }
.seg-row.selected { background: #e8f0fe; }
.seg-row.dimmed { opacity: 0.4; }
.seg-check {
width: 16px; height: 16px; accent-color: #0078d4; cursor: pointer;
flex-shrink: 0;
}
.seg-ops { display: flex; gap: 4px; margin-bottom: 8px; }
.seg-op-btn {
padding: 3px 10px; border: 1px solid #e0e0e0; border-radius: 4px;
background: #fff; color: #666; font-size: 11px; cursor: pointer;
transition: all 0.15s;
}
.seg-op-btn:hover { background: #f0f0f0; border-color: #ccc; }
/* Toast */ /* Toast */
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; } .toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
.toast { .toast {
@@ -193,6 +221,90 @@
.toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; } .toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
/* 模式切换 */
.mode-toggle {
display: flex; gap: 0; margin-bottom: 12px;
border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden;
}
.mode-btn {
flex: 1; padding: 7px 0; text-align: center;
font-size: 12px; font-weight: 600; cursor: pointer;
background: #fff; color: #999; border: none;
transition: all 0.15s;
}
.mode-btn.active { background: #0078d4; color: #fff; }
.mode-btn:first-child { border-right: 1px solid #e0e0e0; }
.mode-btn.active:first-child { border-right-color: #0078d4; }
/* 切割点列表 */
.cut-list { margin-top: 8px; }
.cut-row {
display: flex; align-items: center; gap: 6px;
padding: 5px 0; border-bottom: 1px solid #f0f0f0;
}
.cut-row:last-child { border-bottom: none; }
.cut-idx { width: 24px; font-size: 12px; font-weight: 700; color: #999; text-align: center; }
.cut-input {
flex: 1; padding: 4px 8px; border: 1px solid #e0e0e0; border-radius: 4px;
font-size: 12px; text-align: center; outline: none;
transition: border-color 0.15s;
}
.cut-input:focus { border-color: #0078d4; }
.cut-del {
width: 24px; height: 24px; border: none; background: none;
color: #ccc; cursor: pointer; font-size: 12px; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.cut-del:hover { background: #fce4ec; color: #c62828; }
.cut-add {
display: flex; align-items: center; justify-content: center; gap: 4px;
width: 100%; padding: 6px; border: 1px dashed #e0e0e0; border-radius: 6px;
background: none; color: #999; font-size: 12px; cursor: pointer;
margin-top: 6px; transition: all 0.15s;
}
.cut-add:hover { border-color: #0078d4; color: #0078d4; }
.cut-hint { font-size: 11px; color: #aaa; margin-top: 8px; line-height: 1.5; }
/* 时间轴 (HTML div 实现) */
.timeline-wrap { position: relative; margin-bottom: 28px; margin-top: 22px; padding-bottom: 22px; }
#timelineBar {
position: relative; height: 100px;
border-radius: 8px; border: 1px solid #e0e0e0;
cursor: crosshair; user-select: none; overflow: visible;
background: #f0f4f8;
}
#timelineBar.readonly { cursor: default; }
#timelineBar.readonly .cut-marker { cursor: default; }
#waveformCanvas {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
border-radius: 8px; pointer-events: none;
}
.cut-marker {
position: absolute; top: 0; width: 24px; height: 100%;
transform: translateX(-50%); z-index: 2; cursor: grab;
}
.cut-marker:active { cursor: grabbing; }
.cut-marker-line {
position: absolute; left: 50%; top: 0; bottom: 0;
width: 2px; background: #e53935; transform: translateX(-50%);
}
.cut-marker-tri {
position: absolute; left: 50%; top: -1px;
transform: translateX(-50%);
width: 0; height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid #e53935;
pointer-events: none;
}
.cut-marker-label {
position: absolute; left: 50%; bottom: -20px;
transform: translateX(-50%);
font-size: 10px; color: #e53935; font-weight: 600;
white-space: nowrap; pointer-events: none;
}
@media (max-width: 768px) { @media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; } body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; } .left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
@@ -239,11 +351,17 @@
</div> </div>
</div> </div>
<!-- 参数 --> <!-- 模式切换 -->
<div class="section-card"> <div class="mode-toggle">
<div class="mode-btn active" id="modeAuto" onclick="setCutMode('auto')">自动切割</div>
<div class="mode-btn" id="modeManual" onclick="setCutMode('manual')">手动切割</div>
</div>
<!-- 自动模式:参数 -->
<div class="section-card" id="autoParamsCard">
<div class="section-title"> <div class="section-title">
<i class="fas fa-wrench"></i> 分割参数 <i class="fas fa-wrench"></i> 分割参数
<button class="slice-btn" style="margin-left:auto;" onclick="saveConfig()" title="保存参数"><i class="fas fa-check"></i></button> <button class="param-save-btn" onclick="saveConfig()"><i class="fas fa-check"></i> 保存</button>
</div> </div>
<div class="param-row"> <div class="param-row">
<span class="param-label">静音阈值</span> <span class="param-label">静音阈值</span>
@@ -277,6 +395,16 @@
</div> </div>
</div> </div>
<!-- 手动模式:切割点 -->
<div class="section-card" id="manualCutCard" style="display:none;">
<div class="section-title">
<i class="fas fa-scissors"></i> 切割点
</div>
<div class="cut-list" id="cutList"></div>
<button class="cut-add" onclick="addCutPointRow()"><i class="fas fa-plus"></i> 添加切割点</button>
<div class="cut-hint">点击时间轴添加标记,拖动标记调整位置,点击已有标记删除</div>
</div>
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="btn-row"> <div class="btn-row">
<button class="action-btn analyze" id="btnAnalyze" onclick="doAnalyze()" disabled> <button class="action-btn analyze" id="btnAnalyze" onclick="doAnalyze()" disabled>
@@ -285,6 +413,9 @@
<button class="action-btn slice" id="btnSlice" onclick="doSlice()" disabled> <button class="action-btn slice" id="btnSlice" onclick="doSlice()" disabled>
<i class="fas fa-scissors"></i> 开始切割 <i class="fas fa-scissors"></i> 开始切割
</button> </button>
<button class="action-btn slice" id="btnManualSlice" onclick="doManualSlice()" style="display:none;" disabled>
<i class="fas fa-scissors"></i> 开始切割
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -298,7 +429,15 @@
<i class="fas fa-scissors"></i> <i class="fas fa-scissors"></i>
<p>上传音频文件后开始分析</p> <p>上传音频文件后开始分析</p>
</div> </div>
<div id="resultArea" style="display:none;"> <div id="timelineArea" style="display:none;">
<div class="timeline-wrap" id="timelineBar"></div>
<div id="redoWrap" style="display:none;text-align:center;margin-top:8px;">
<button class="action-btn slice" style="width:auto;padding:8px 20px;font-size:13px;" onclick="redoManualSlice()">
<i class="fas fa-redo"></i> 切割点已更改,点击重新切割
</button>
</div>
</div>
<div id="resultArea" style="display:none;margin-top: 20px">
<div class="top-actions" id="topActions" style="display:none;"> <div class="top-actions" id="topActions" style="display:none;">
<button class="top-btn primary" onclick="downloadAll()"> <button class="top-btn primary" onclick="downloadAll()">
<i class="fas fa-folder-plus"></i> 打包下载全部 <i class="fas fa-folder-plus"></i> 打包下载全部
@@ -317,6 +456,16 @@
let taskId = null; let taskId = null;
let pollTimer = null; let pollTimer = null;
// 手动切割状态
let cutMode = 'auto';
let cutPoints = [];
let audioDuration = 0;
let cutPointsChanged = false;
let selectedSegments = []; // 选中的片段索引
let manualSegSelected = []; // 手动模式:左侧面板勾选的片段
let manualSegChanged = false; // 用户是否手动调整过勾选
let manualSegInit = false; // 新建预览时重置勾选
function showMessage(text, type, duration) { function showMessage(text, type, duration) {
type = type || 'info'; duration = duration || 3000; type = type || 'info'; duration = duration || 3000;
var t = document.createElement('div'); var t = document.createElement('div');
@@ -404,11 +553,23 @@
document.getElementById('infoDur').textContent = formatDur(info.duration); document.getElementById('infoDur').textContent = formatDur(info.duration);
document.getElementById('infoName').textContent = data.filename; document.getElementById('infoName').textContent = data.filename;
document.getElementById('btnAnalyze').disabled = false; document.getElementById('btnAnalyze').disabled = false;
// 存储时长用于手动模式
audioDuration = info.duration;
cutPoints = [];
cutPointsChanged = false;
waveformPeaks = null;
// 重置右侧面板 // 重置右侧面板
document.getElementById('emptyState').style.display = 'flex'; document.getElementById('emptyState').style.display = 'flex';
document.getElementById('resultArea').style.display = 'none'; document.getElementById('resultArea').style.display = 'none';
document.getElementById('timelineArea').style.display = 'none';
document.getElementById('redoWrap').style.display = 'none';
document.getElementById('sliceList').innerHTML = ''; document.getElementById('sliceList').innerHTML = '';
document.getElementById('topActions').style.display = 'none'; document.getElementById('topActions').style.display = 'none';
// 两种模式都显示时间轴(自动模式只读,手动模式可交互)
showTimeline(cutMode === 'auto');
updateManualBtn();
// 异步加载波形数据
loadWaveform();
} catch (e) { } catch (e) {
showMessage('上传失败: ' + e.message, 'error'); showMessage('上传失败: ' + e.message, 'error');
document.getElementById('uploadLabel').textContent = '点击或拖拽音频文件到此处'; document.getElementById('uploadLabel').textContent = '点击或拖拽音频文件到此处';
@@ -440,6 +601,8 @@
document.getElementById('statusText').textContent = '分析完成: 检测到 ' + data.count + ' 个片段'; document.getElementById('statusText').textContent = '分析完成: 检测到 ' + data.count + ' 个片段';
renderPreview(data.preview, data.sample_rate); renderPreview(data.preview, data.sample_rate);
document.getElementById('btnSlice').disabled = false; document.getElementById('btnSlice').disabled = false;
// 自动模式也显示时间轴(只读)
showTimeline(true);
} catch (e) { } catch (e) {
showMessage('分析失败: ' + e.message, 'error'); showMessage('分析失败: ' + e.message, 'error');
} finally { } finally {
@@ -449,18 +612,95 @@
} }
function renderPreview(preview, sr) { function renderPreview(preview, sr) {
var html = '<table class="preview-table"><thead><tr>' // 默认全部选中
+ '<th>#</th><th>时长</th><th>采样数</th></tr></thead><tbody>'; selectedSegments = [];
for (var i = 0; i < preview.length; i++) selectedSegments.push(i);
// 计算每段起止秒数(用于切割前预览播放)
window._previewRanges = [];
var cumSamples = 0;
for (var i = 0; i < preview.length; i++) {
var startSec = cumSamples / sr;
cumSamples += preview[i].samples;
var endSec = cumSamples / sr;
window._previewRanges.push([startSec, endSec]);
}
var html = '<div style="margin-bottom:10px;">'
+ '<span style="font-size:13px;font-weight:600;color:#333;"><i class="fas fa-list-check" style="color:#0078d4;margin-right:4px;"></i> 片段选择</span>'
+ '<span style="font-size:11px;color:#999;margin-left:8px;">点击取消勾选不需要的片段</span>'
+ '</div>'
+ '<div class="seg-ops">'
+ '<button class="seg-op-btn" onclick="selectAllSegments()">全选</button>'
+ '<button class="seg-op-btn" onclick="deselectAllSegments()">全不选</button>'
+ '<span id="segCount" style="font-size:11px;color:#999;margin-left:4px;">已选 ' + preview.length + '/' + preview.length + '</span>'
+ '</div>';
for (var i = 0; i < preview.length; i++) { for (var i = 0; i < preview.length; i++) {
var p = preview[i]; var p = preview[i];
html += '<tr><td>' + (i + 1) + '</td><td>' + formatDur(p.duration) + '</td><td>' + p.samples.toLocaleString() + '</td></tr>'; html += '<div class="seg-row selected" id="segRow' + i + '" onclick="toggleSegment(' + i + ')">'
+ '<input type="checkbox" class="seg-check" id="segCb' + i + '" checked tabindex="-1">'
+ '<span style="width:24px;font-size:13px;font-weight:700;color:#999;text-align:center;">' + (i + 1) + '</span>'
+ '<span style="flex:1;font-size:13px;color:#333;">' + formatDur(p.duration) + '</span>'
+ '<span style="font-size:11px;color:#999;">' + p.samples.toLocaleString() + ' samples</span>'
+ '<button class="slice-btn play-btn" onclick="event.stopPropagation();playPreviewSeg(' + i + ')" title="试听"><i class="fas fa-play"></i></button>'
+ '</div>';
} }
html += '</tbody></table>';
document.getElementById('sliceList').innerHTML = html; document.getElementById('sliceList').innerHTML = html;
} }
function playPreviewSeg(index) {
if (!taskId || !window._previewRanges || !window._previewRanges[index]) return;
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
var r = window._previewRanges[index];
var audio = new Audio('/audio-slicer/preview-range/' + taskId + '?start=' + r[0] + '&end=' + r[1]);
audio.play();
currentAudio = audio;
}
function toggleSegment(idx) {
var pos = selectedSegments.indexOf(idx);
if (pos >= 0) {
selectedSegments.splice(pos, 1);
} else {
selectedSegments.push(idx);
selectedSegments.sort(function(a, b) { return a - b; });
}
// Sync checkbox visual state
var cb = document.getElementById('segCb' + idx);
if (cb) cb.checked = (pos < 0);
updateSegUI();
}
function selectAllSegments() {
var rows = document.querySelectorAll('.seg-row');
selectedSegments = [];
for (var i = 0; i < rows.length; i++) selectedSegments.push(i);
updateSegUI();
}
function deselectAllSegments() {
selectedSegments = [];
updateSegUI();
}
function updateSegUI() {
var rows = document.querySelectorAll('.seg-row');
for (var i = 0; i < rows.length; i++) {
var sel = selectedSegments.indexOf(i) >= 0;
rows[i].className = 'seg-row' + (sel ? ' selected' : ' dimmed');
var cb = document.getElementById('segCb' + i);
if (cb) cb.checked = sel;
}
var total = rows.length;
var el = document.getElementById('segCount');
if (el) el.textContent = '已选 ' + selectedSegments.length + '/' + total;
var btnSlice = document.getElementById('btnSlice');
if (btnSlice) btnSlice.disabled = selectedSegments.length === 0;
}
async function doSlice() { async function doSlice() {
if (!taskId) return; if (!taskId) return;
if (selectedSegments.length === 0) { showMessage('请至少选择一个片段', 'error'); return; }
var btn = document.getElementById('btnSlice'); var btn = document.getElementById('btnSlice');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 切割中...'; btn.innerHTML = '<i class="fas fa-refresh"></i> 切割中...';
@@ -474,7 +714,7 @@
try { try {
var res = await fetch('/audio-slicer/slice', { var res = await fetch('/audio-slicer/slice', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId }) body: JSON.stringify({ task_id: taskId, selected_indices: selectedSegments })
}); });
var data = await res.json(); var data = await res.json();
if (!data.success) { if (!data.success) {
@@ -511,6 +751,14 @@
document.getElementById('btnSlice').disabled = false; document.getElementById('btnSlice').disabled = false;
document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割'; document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
document.getElementById('btnAnalyze').disabled = false; document.getElementById('btnAnalyze').disabled = false;
document.getElementById('btnManualSlice').disabled = false;
document.getElementById('btnManualSlice').innerHTML = '<i class="fas fa-scissors"></i> 重新切割';
// 手动模式下保持时间轴可见,支持拖动调整后重新切割
if (cutMode === 'manual') {
document.getElementById('timelineArea').style.display = 'block';
cutPointsChanged = false;
document.getElementById('redoWrap').style.display = 'none';
}
} else if (data.status === 'error') { } else if (data.status === 'error') {
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = null; pollTimer = null;
@@ -519,6 +767,8 @@
document.getElementById('btnSlice').disabled = false; document.getElementById('btnSlice').disabled = false;
document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割'; document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
document.getElementById('btnAnalyze').disabled = false; document.getElementById('btnAnalyze').disabled = false;
document.getElementById('btnManualSlice').disabled = false;
document.getElementById('btnManualSlice').innerHTML = '<i class="fas fa-scissors"></i> 重新切割';
} }
} catch (e) {} } catch (e) {}
}, 500); }, 500);
@@ -561,6 +811,469 @@
a.download = ''; a.download = '';
a.click(); a.click();
} }
// ── 模式切换 ──────────────────────────────────────────────────────
function setCutMode(mode) {
cutMode = mode;
document.getElementById('modeAuto').classList.toggle('active', mode === 'auto');
document.getElementById('modeManual').classList.toggle('active', mode === 'manual');
document.getElementById('autoParamsCard').style.display = mode === 'auto' ? 'block' : 'none';
document.getElementById('manualCutCard').style.display = mode === 'manual' ? 'block' : 'none';
document.getElementById('btnAnalyze').style.display = mode === 'auto' ? '' : 'none';
document.getElementById('btnSlice').style.display = mode === 'auto' ? '' : 'none';
document.getElementById('btnManualSlice').style.display = mode === 'manual' ? '' : 'none';
// 切换时清空右侧面板
document.getElementById('resultArea').style.display = 'none';
document.getElementById('redoWrap').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
document.getElementById('topActions').style.display = 'none';
document.getElementById('progressWrap').style.display = 'none';
cutPointsChanged = false;
// 两种模式都显示时间轴(自动模式只读,手动模式可交互)
if (audioDuration > 0) {
showTimeline(mode === 'auto');
}
}
function showTimeline(readOnly) {
document.getElementById('emptyState').style.display = 'none';
document.getElementById('timelineArea').style.display = 'block';
renderTimeline(readOnly);
}
function updateManualBtn() {
var btn = document.getElementById('btnManualSlice');
if (btn) btn.disabled = !(taskId && cutPoints.length > 0 && manualSegSelected.length > 0);
}
// ── 切割点管理 ────────────────────────────────────────────────────
function addCutPoint(sec) {
sec = Math.round(sec * 100) / 100;
if (sec <= 0 || sec >= audioDuration) return;
for (var i = 0; i < cutPoints.length; i++) {
if (Math.abs(cutPoints[i] - sec) < 0.05) return;
}
cutPoints.push(sec);
cutPoints.sort(function(a, b) { return a - b; });
manualSegInit = true;
renderCutList();
renderTimeline();
updateManualBtn();
markCutPointsChanged();
}
function removeCutPoint(idx) {
cutPoints.splice(idx, 1);
manualSegInit = true;
renderCutList();
renderTimeline();
updateManualBtn();
markCutPointsChanged();
}
function addCutPointRow() {
if (audioDuration <= 0) { showMessage('请先上传音频', 'error'); return; }
// 默认添加到中间位置
var sec = audioDuration / 2;
if (cutPoints.length > 0) {
// 找最大的间隔中点
var maxGap = 0, maxIdx = 0;
var pts = [0].concat(cutPoints).concat([audioDuration]);
for (var i = 1; i < pts.length; i++) {
var gap = pts[i] - pts[i - 1];
if (gap > maxGap) { maxGap = gap; maxIdx = i; }
}
sec = (pts[maxIdx - 1] + pts[maxIdx]) / 2;
}
addCutPoint(sec);
}
function renderCutList() {
var list = document.getElementById('cutList');
if (!list) return;
if (cutPoints.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#ccc;font-size:12px;padding:8px;">暂无切割点</div>';
manualSegSelected = [];
return;
}
// 新预览时重置勾选
if (manualSegInit) {
manualSegInit = false;
manualSegSelected = [];
manualSegChanged = false;
}
var html = '';
for (var i = 0; i < cutPoints.length; i++) {
html += '<div class="cut-row">'
+ '<span class="cut-idx"><i class="fas fa-scissors" style="font-size:10px;color:#e53935;"></i></span>'
+ '<input class="cut-input" value="' + formatTime(cutPoints[i]) + '" '
+ 'onchange="updateCutPoint(' + i + ', this.value)" '
+ 'onkeydown="if(event.key===\'Enter\')this.blur()">'
+ '<button class="cut-del" onclick="removeCutPoint(' + i + ')" title="删除"><i class="fas fa-times"></i></button>'
+ '</div>';
}
var boundaries = [0].concat(cutPoints).concat([audioDuration]);
// 确保勾选数组长度匹配
if (manualSegSelected.length !== boundaries.length - 1) {
manualSegSelected = [];
for (var j = 0; j < boundaries.length - 1; j++) manualSegSelected.push(j);
}
html += '<div style="border-top:1px solid #e0e0e0;margin-top:8px;padding-top:8px;">'
+ '<div style="font-size:11px;font-weight:600;color:#999;margin-bottom:6px;">'
+ '<i class="fas fa-list-check" style="color:#0078d4;margin-right:3px;"></i>预览片段 '
+ '<span style="font-weight:400;color:#aaa;">点击取消勾选不需要的片段</span>'
+ '</div>'
+ '<div class="seg-ops">'
+ '<button class="seg-op-btn" onclick="selectManualSegments()">全选</button>'
+ '<span id="manualSegCount" style="font-size:11px;color:#999;margin-left:4px;"></span>'
+ '</div>';
for (var i = 0; i < boundaries.length - 1; i++) {
var s = boundaries[i], e = boundaries[i + 1];
var dur = e - s;
var sel = manualSegSelected.indexOf(i) >= 0;
html += '<div class="seg-row' + (sel ? ' selected' : ' dimmed') + '" onclick="toggleManualSeg(' + i + ')">'
+ '<input type="checkbox" class="seg-check" id="mSegCb' + i + '"' + (sel ? ' checked' : '') + ' tabindex="-1">'
+ '<span style="width:24px;font-size:13px;font-weight:700;color:#999;text-align:center;">' + (i + 1) + '</span>'
+ '<span style="flex:1;font-size:12px;color:#555;">' + formatTime(s) + ' → ' + formatTime(e) + '</span>'
+ '<span style="font-size:11px;color:#999;width:50px;text-align:right;">' + formatDur(dur) + '</span>'
+ '<button class="cut-del" style="color:#0078d4;" onclick="event.stopPropagation();playPreview(' + s + ',' + e + ')" title="试听"><i class="fas fa-play"></i></button>'
+ '</div>';
}
html += '</div>';
list.innerHTML = html;
updateManualSegCount();
}
function updateCutPoint(idx, val) {
var sec = parseTime(val);
if (isNaN(sec) || sec <= 0 || sec >= audioDuration) {
showMessage('无效的时间', 'error');
renderCutList();
return;
}
cutPoints[idx] = Math.round(sec * 100) / 100;
cutPoints.sort(function(a, b) { return a - b; });
renderCutList();
renderTimeline();
markCutPointsChanged();
}
var previewAudio = null;
function playPreview(start, end) {
if (!taskId) return;
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
previewAudio = new Audio('/audio-slicer/preview-range/' + taskId + '?start=' + start + '&end=' + end);
previewAudio.play();
}
function toggleManualSeg(idx) {
var pos = manualSegSelected.indexOf(idx);
if (pos >= 0) {
manualSegSelected.splice(pos, 1);
} else {
manualSegSelected.push(idx);
manualSegSelected.sort(function(a, b) { return a - b; });
}
manualSegChanged = true;
var cb = document.getElementById('mSegCb' + idx);
if (cb) cb.checked = (pos < 0);
var row = cb ? cb.closest('.seg-row') : null;
if (row) row.className = 'seg-row' + (pos < 0 ? ' selected' : ' dimmed');
updateManualSegCount();
updateManualBtn();
}
function selectManualSegments() {
manualSegSelected = [];
for (var i = 0; i < cutPoints.length + 1; i++) manualSegSelected.push(i);
manualSegChanged = true;
renderCutList();
}
function updateManualSegCount() {
var total = cutPoints.length + 1;
var el = document.getElementById('manualSegCount');
if (el) el.textContent = '已选 ' + manualSegSelected.length + '/' + total;
}
function formatTime(sec) {
var m = Math.floor(sec / 60);
var s = (sec % 60).toFixed(2);
if (s < 10) s = '0' + s;
return m + ':' + s;
}
function parseTime(str) {
str = str.trim();
if (str.indexOf(':') !== -1) {
var parts = str.split(':');
return parseFloat(parts[0]) * 60 + parseFloat(parts[1]);
}
return parseFloat(str);
}
// ── 时间轴 (HTML div 实现,参考 audio-cutter 项目) ────────────────
var waveformPeaks = null;
function renderTimeline(readOnly) {
var bar = document.getElementById('timelineBar');
if (!bar || audioDuration <= 0) return;
bar.className = readOnly ? 'readonly' : '';
var html = '<canvas id="waveformCanvas"></canvas>';
// 时间刻度(在波形条上方)
var step = Math.max(1, Math.ceil(audioDuration / 10));
for (var t = 0; t <= audioDuration; t += step) {
var pct = (t / audioDuration * 100).toFixed(2);
html += '<div style="position:absolute;left:' + pct + '%;top:0;bottom:0;border-left:1px solid rgba(0,0,0,0.08);z-index:0;"></div>'
+ '<div style="position:absolute;left:' + pct + '%;top:-18px;transform:translateX(-50%);font-size:10px;color:#999;pointer-events:none;">' + formatTime(t) + '</div>';
}
// 切割标记 div倒三角在上时间在下
for (var i = 0; i < cutPoints.length; i++) {
var pct = (cutPoints[i] / audioDuration * 100).toFixed(2);
html += '<div class="cut-marker" data-idx="' + i + '" style="left:' + pct + '%;">'
+ '<div class="cut-marker-tri"></div>'
+ '<div class="cut-marker-line"></div>'
+ '<div class="cut-marker-label">' + formatTime(cutPoints[i]) + '</div></div>';
}
bar.innerHTML = html;
// 绘制波形
if (waveformPeaks) drawWaveform();
if (readOnly) return; // 只读模式不绑定交互事件
// 时间轴空白区域点击 → 添加切割点
if (!bar._bound) {
bar._bound = true;
bar.addEventListener('click', function(e) {
if (bar._suppressClick) { bar._suppressClick = false; return; }
if (bar.classList.contains('readonly')) return;
if (e.target.closest('.cut-marker')) return;
var rect = bar.getBoundingClientRect();
var sec = (e.clientX - rect.left) / rect.width * audioDuration;
if (sec > 0.5 && sec < audioDuration - 0.5) addCutPoint(sec);
});
}
// 为每个标记绑定拖动
var markers = bar.querySelectorAll('.cut-marker');
for (var i = 0; i < markers.length; i++) {
bindMarkerDrag(markers[i], bar);
}
}
function drawWaveform() {
var canvas = document.getElementById('waveformCanvas');
if (!canvas || !waveformPeaks || waveformPeaks.length === 0) return;
var bar = document.getElementById('timelineBar');
canvas.width = bar.clientWidth;
canvas.height = bar.clientHeight;
var ctx = canvas.getContext('2d');
var W = canvas.width, H = canvas.height;
var peaks = waveformPeaks;
var barW = W / peaks.length;
var midY = H / 2;
// 背景渐变
var grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#b3d4fc');
grad.addColorStop(0.5, '#0078d4');
grad.addColorStop(1, '#b3d4fc');
ctx.fillStyle = grad;
for (var i = 0; i < peaks.length; i++) {
var h = peaks[i] * midY * 0.9;
var x = i * barW;
ctx.fillRect(x, midY - h, Math.max(barW - 0.5, 1), h * 2);
}
}
async function loadWaveform() {
if (!taskId) return;
try {
var res = await fetch('/audio-slicer/waveform-peaks/' + taskId + '?samples=500');
var data = await res.json();
if (data.peaks) {
var max = 0;
for (var i = 0; i < data.peaks.length; i++) {
if (data.peaks[i] > max) max = data.peaks[i];
}
if (max > 0) {
for (var i = 0; i < data.peaks.length; i++) {
data.peaks[i] = data.peaks[i] / max;
}
}
waveformPeaks = data.peaks;
drawWaveform();
}
} catch (e) {}
}
function bindMarkerDrag(el, bar) {
el.addEventListener('pointerdown', function(e) {
if (e.button === 2) return;
e.preventDefault();
e.stopPropagation();
el.style.touchAction = 'none';
var startX = e.clientX;
var hasMoved = false;
var idx = parseInt(el.dataset.idx);
function onMove(e2) {
e2.preventDefault();
var dx = e2.clientX - startX;
if (!hasMoved && Math.abs(dx) >= 5) {
hasMoved = true;
el.style.cursor = 'grabbing';
el.style.zIndex = '10';
}
if (hasMoved) {
var rect = bar.getBoundingClientRect();
var sec = (e2.clientX - rect.left) / rect.width * audioDuration;
sec = Math.round(sec * 100) / 100;
sec = Math.max(0.1, Math.min(audioDuration - 0.1, sec));
cutPoints[idx] = sec;
el.style.left = (sec / audioDuration * 100).toFixed(2) + '%';
el.querySelector('.cut-marker-label').textContent = formatTime(sec);
startX = e2.clientX;
}
}
function onUp() {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
el.style.touchAction = '';
el.style.cursor = '';
el.style.zIndex = '';
if (hasMoved) {
cutPoints.sort(function(a, b) { return a - b; });
renderCutList();
renderTimeline();
markCutPointsChanged();
bar._suppressClick = true;
} else {
removeCutPoint(idx);
}
}
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
});
}
// ── 手动切割 ──────────────────────────────────────────────────────
async function doManualSlice() {
if (!taskId || cutPoints.length === 0) return;
if (manualSegSelected.length === 0) { showMessage('请至少选择一个片段', 'error'); return; }
var btn = document.getElementById('btnManualSlice');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 处理中...';
// 先提交切割点获取片段
try {
var res = await fetch('/audio-slicer/manual-slice', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId, cut_points: cutPoints })
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
return;
}
document.getElementById('emptyState').style.display = 'none';
document.getElementById('resultArea').style.display = 'block';
document.getElementById('statusText').textContent = '已标记 ' + data.count + ' 段,开始切割...';
renderPreview(data.preview, data.sample_rate);
} catch (e) {
showMessage('请求失败: ' + e.message, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
return;
}
// 切割选中的片段
document.getElementById('progressWrap').style.display = 'block';
document.getElementById('progressFill').style.width = '0%';
document.getElementById('topActions').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
try {
var res = await fetch('/audio-slicer/slice', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId, selected_indices: manualSegSelected })
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
return;
}
cutPointsChanged = false;
document.getElementById('redoWrap').style.display = 'none';
startPoll();
} catch (e) {
showMessage('请求失败: ' + e.message, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
}
}
function markCutPointsChanged() {
if (!cutPointsChanged && document.getElementById('resultArea').style.display !== 'none') {
cutPointsChanged = true;
document.getElementById('redoWrap').style.display = 'block';
}
}
async function redoManualSlice() {
if (!taskId || cutPoints.length === 0) return;
if (manualSegSelected.length === 0) { showMessage('请至少选择一个片段', 'error'); return; }
document.getElementById('redoWrap').style.display = 'none';
cutPointsChanged = false;
// 跳过重新预览,直接用当前勾选切割
var btn = document.getElementById('btnManualSlice');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 切割中...';
document.getElementById('progressWrap').style.display = 'block';
document.getElementById('progressFill').style.width = '0%';
document.getElementById('statusText').textContent = '正在重新切割...';
document.getElementById('topActions').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
try {
var res = await fetch('/audio-slicer/slice', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId, selected_indices: manualSegSelected })
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
return;
}
startPoll();
} catch (e) {
showMessage('请求失败: ' + e.message, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
}
}
renderCutList();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -34,7 +34,9 @@
padding: 0 20px 24px; padding: 0 20px 24px;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
margin-bottom: 20px; margin-bottom: 20px;
display: flex; align-items: flex-start; justify-content: space-between;
} }
.sidebar-header-left { flex: 1; }
.sidebar-title { .sidebar-title {
font-size: 18px; font-size: 18px;
@@ -48,6 +50,14 @@
margin-top: 4px; margin-top: 4px;
} }
.sidebar-settings-btn {
width: 30px; height: 30px; border: none; background: none;
border-radius: 8px; cursor: pointer; font-size: 14px; color: #bbb;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; flex-shrink: 0; margin-top: 2px;
}
.sidebar-settings-btn:hover { background: #f0f0f0; color: #666; }
.nav-section { .nav-section {
padding: 0 12px; padding: 0 12px;
margin-bottom: 24px; margin-bottom: 24px;
@@ -125,20 +135,6 @@
.sidebar-search-input input::placeholder { color: #bbb; } .sidebar-search-input input::placeholder { color: #bbb; }
.nav-item.hidden { display: none; } .nav-item.hidden { display: none; }
/* 侧边栏底部设置按钮 */
.sidebar-footer {
margin-top: auto; padding: 12px 16px 8px;
border-top: 1px solid #e0e0e0;
}
.sidebar-settings-btn {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 8px 10px; border: none; background: none;
border-radius: 8px; cursor: pointer; font-size: 13px; color: #999;
transition: all 0.15s;
}
.sidebar-settings-btn:hover { background: #f0f0f0; color: #666; }
.sidebar-settings-btn i { width: 20px; text-align: center; }
/* 功能设置弹窗 */ /* 功能设置弹窗 */
.feat-modal-overlay { .feat-modal-overlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
@@ -556,8 +552,13 @@
<!-- 侧边栏 --> <!-- 侧边栏 -->
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-title">LEVIN工具集</div> <div class="sidebar-header-left">
<div class="sidebar-subtitle">私人工具</div> <div class="sidebar-title">LEVIN工具集</div>
<div class="sidebar-subtitle">私人工具合集</div>
</div>
<button class="sidebar-settings-btn" onclick="openFeatModal()" title="功能显示设置">
<i class="fas fa-gear"></i>
</button>
</div> </div>
<div class="sidebar-search"> <div class="sidebar-search">
@@ -567,100 +568,13 @@
</div> </div>
</div> </div>
<nav class="nav-section"> <nav class="nav-section" id="nav-section-overview">
<div class="nav-section-title">概览</div> <div class="nav-section-title">概览</div>
<a class="nav-item active" id="nav-dashboard" data-feature="dashboard" onclick="showDashboard()">
<i class="fas fa-chart-bar"></i>
仪表盘
</a>
<a class="nav-item" data-url="/data-manage" data-feature="data-manage" onclick="loadTool(this, '/data-manage')">
<i class="fas fa-database"></i>
数据管理
</a>
</nav> </nav>
<nav class="nav-section"> <nav class="nav-section" id="nav-section-tools">
<div class="nav-section-title">工具</div> <div class="nav-section-title">工具</div>
<a class="nav-item" data-url="/pin-tu" data-feature="pin-tu" onclick="loadTool(this, '/pin-tu')">
<i class="fas fa-images"></i>
路径文件查阅器
</a>
<a class="nav-item" data-url="/down-video" data-feature="down-video" onclick="loadTool(this, '/down-video')">
<i class="fas fa-video"></i>
XBIY视频下载器
</a>
<a class="nav-item" data-url="/content-tag" data-feature="content-tag" onclick="loadTool(this, '/content-tag')">
<i class="fas fa-tags"></i>
AI生成文章标签
</a>
<a class="nav-item" data-url="/audio-slicer" data-feature="audio-slicer" onclick="loadTool(this, '/audio-slicer')">
<i class="fas fa-scissors"></i>
AS 音频分割
</a>
<a class="nav-item" data-url="/stt" data-feature="stt" onclick="loadTool(this, '/stt')">
<i class="fas fa-microphone"></i>
STT 语音转文字
</a>
<a class="nav-item" data-url="/uvr-sep" data-feature="uvr-sep" onclick="loadTool(this, '/uvr-sep')">
<i class="fas fa-exchange-alt"></i>
UVR 人声分离
</a>
<a class="nav-item" data-url="/rvc" data-feature="rvc" onclick="loadTool(this, '/rvc')">
<i class="fas fa-microphone"></i>
RVC 音色转换
</a>
<a class="nav-item" data-url="/ai-dubbing" data-feature="ai-dubbing" onclick="loadTool(this, '/ai-dubbing')">
<i class="fas fa-microphone"></i>
GPT-SoVITS (引擎版)
</a>
<a class="nav-item" data-url="/sovits-tts" data-feature="sovits-tts" onclick="loadTool(this, '/sovits-tts')">
<i class="fas fa-microphone"></i>
GPT-SoVITS (接口版)
</a>
<a class="nav-item" data-url="/fen-ci" data-feature="fen-ci" onclick="loadTool(this, '/fen-ci')">
<i class="fas fa-scissors"></i>
Jieba 分词网页版
</a>
<a class="nav-item" data-url="/base64-de-in-code" data-feature="base64" onclick="loadTool(this, '/base64-de-in-code')">
<i class="fas fa-lock"></i>
Base64 编码/解码
</a>
<a class="nav-item" data-url="/json-format" data-feature="json-format" onclick="loadTool(this, '/json-format')">
<i class="fas fa-code"></i>
JSON 美化/压缩
</a>
<a class="nav-item" data-url="/http-status" data-feature="http-status" onclick="loadTool(this, '/http-status')">
<i class="fas fa-info-circle"></i>
HTTP 状态码查询
</a>
<a class="nav-item" data-url="/url-parser" data-feature="url-parser" onclick="loadTool(this, '/url-parser')">
<i class="fas fa-link"></i>
URL 路径解析器
</a>
<a class="nav-item" data-url="/chmod-calc" data-feature="chmod-calc" onclick="loadTool(this, '/chmod-calc')">
<i class="fas fa-key"></i>
Chmod 计算器
</a>
<a class="nav-item" data-url="/token-gen" data-feature="token-gen" onclick="loadTool(this, '/token-gen')">
<i class="fas fa-lock"></i>
Token 随机生成器
</a>
<a class="nav-item" data-url="/qr-code" data-feature="qr-code" onclick="loadTool(this, '/qr-code')">
<i class="fas fa-qrcode"></i>
Qr-code 生成器
</a>
</nav> </nav>
<div class="sidebar-footer">
<button class="sidebar-settings-btn" onclick="openFeatModal()">
<i class="fas fa-eye"></i> 功能显示设置
</button>
</div>
</aside> </aside>
<!-- 功能显示设置弹窗 --> <!-- 功能显示设置弹窗 -->
@@ -1342,58 +1256,67 @@
Object.values(charts).forEach(c => c && c.resize()); Object.values(charts).forEach(c => c && c.resize());
}); });
// ===== 功能显示设置 ===== // ===== 侧边栏动态生成 =====
var FEATURES = [ var NAV_ITEMS = [
{ id: 'dashboard', name: '仪表盘', section: '概览' }, { id: 'dashboard', name: '仪表盘', icon: 'fa-chart-bar', url: null, section: '概览' },
{ id: 'data-manage', name: '数据管理', section: '概览' }, { id: 'data-manage', name: '数据管理', icon: 'fa-database', url: '/data-manage', section: '概览' },
{ id: 'pin-tu', name: '路径文件查阅器', section: '工具' }, { id: 'pin-tu', name: '路径文件查阅器', icon: 'fa-images', url: '/pin-tu', section: '工具' },
{ id: 'down-video', name: 'XBIY视频下载器', section: '工具' }, { id: 'down-video', name: 'XBIY视频下载器', icon: 'fa-video', url: '/down-video', section: '工具' },
{ id: 'content-tag', name: 'AI生成文章标签', section: '工具' }, { id: 'ai-translate', name: 'NLLB 翻译与字幕', icon: 'fa-language', url: '/ai-translate',section: '工具' },
{ id: 'stt', name: 'STT 语音转文字', section: '工具' }, { id: 'content-tag', name: 'AI生成文章标签', icon: 'fa-tags', url: '/content-tag', section: '工具' },
{ id: 'audio-slicer', name: 'AudioSlicer 音频分割', section: '工具' }, { id: 'mp4-to-audio', name: 'VTA 视频转音频', icon: 'fa-video', url: '/mp4-to-audio',section: '工具' },
{ id: 'uvr-sep', name: 'UVR 人声分离', section: '工具' }, { id: 'audio-slicer', name: 'AS 智能音频分割', icon: 'fa-scissors', url: '/audio-slicer',section: '工具' },
{ id: 'ai-dubbing', name: 'GPT-SoVITS (引擎版)', section: '工具' }, { id: 'stt', name: 'STT 语音转文字', icon: 'fa-microphone', url: '/stt', section: '工具' },
{ id: 'rvc', name: 'RVC 语音音色转换', section: '工具' }, { id: 'uvr-sep', name: 'UVR5 人声分离', icon: 'fa-exchange-alt', url: '/uvr-sep', section: '工具' },
{ id: 'fen-ci', name: 'Jieba 分词网页版', section: '工具' }, { id: 'rvc', name: 'RVC 语音音色转换', icon: 'fa-microphone', url: '/rvc', section: '工具' },
{ id: 'base64', name: 'Base64 编码/解码', section: '工具' }, { id: 'ai-dubbing', name: 'GPT-SoVITS (引擎版)', icon: 'fa-microphone', url: '/ai-dubbing', section: '工具' },
{ id: 'json-format', name: 'JSON 美化/压缩', section: '工具' }, { id: 'sovits-tts', name: 'GPT-SoVITS (接口版)', icon: 'fa-microphone', url: '/sovits-tts', section: '工具' },
{ id: 'http-status', name: 'HTTP 状态码查询', section: '工具' }, { id: 'fen-ci', name: 'Jieba 分词网页版', icon: 'fa-scissors', url: '/fen-ci', section: '工具' },
{ id: 'url-parser', name: 'URL 路径解析器', section: '工具' }, { id: 'base64', name: 'Base64 编码/解码', icon: 'fa-lock', url: '/base64-de-in-code', section: '工具' },
{ id: 'chmod-calc', name: 'Chmod 计算器', section: '工具' }, { id: 'json-format', name: 'JSON 美化/压缩', icon: 'fa-code', url: '/json-format', section: '工具' },
{ id: 'token-gen', name: 'Token 随机生成器', section: '工具' }, { id: 'http-status', name: 'HTTP 状态码查询', icon: 'fa-info-circle', url: '/http-status', section: '工具' },
{ id: 'qr-code', name: 'Qr-code 生成器', section: '工具' }, { id: 'url-parser', name: 'URL 路径解析器', icon: 'fa-link', url: '/url-parser', section: '工具' },
{ id: 'sovits-tts', name: 'GPT-SoVITS (接口版)', section: '不常用' } { id: 'chmod-calc', name: 'Chmod 计算器', icon: 'fa-key', url: '/chmod-calc', section: '工具' },
{ id: 'token-gen', name: 'Token 随机生成器', icon: 'fa-lock', url: '/token-gen', section: '工具' },
{ id: 'qr-code', name: 'Qr-code 生成器', icon: 'fa-qrcode', url: '/qr-code', section: '工具' }
]; ];
function getHiddenFeatures() { var _hiddenFeatures = [];
try { return JSON.parse(localStorage.getItem('hidden_features') || '[]'); } catch(e) { return []; }
}
function applyFeatSettings() { function buildSidebar() {
var hidden = getHiddenFeatures(); var hidden = _hiddenFeatures;
document.querySelectorAll('.nav-item[data-feature]').forEach(function(item) { var overviewSection = document.getElementById('nav-section-overview');
var fid = item.getAttribute('data-feature'); var toolsSection = document.getElementById('nav-section-tools');
item.style.display = hidden.indexOf(fid) !== -1 ? 'none' : '';
}); overviewSection.querySelectorAll('.nav-item').forEach(function(el) { el.remove(); });
// 隐藏空的 section-title toolsSection.querySelectorAll('.nav-item').forEach(function(el) { el.remove(); });
document.querySelectorAll('.nav-section').forEach(function(section) {
var hasVisible = false; NAV_ITEMS.forEach(function(item) {
section.querySelectorAll('.nav-item[data-feature]').forEach(function(item) { if (hidden.indexOf(item.id) !== -1) return;
if (item.style.display !== 'none') hasVisible = true; var a = document.createElement('a');
}); a.className = 'nav-item';
var title = section.querySelector('.nav-section-title'); a.setAttribute('data-feature', item.id);
if (title && title.textContent.trim() !== '概览') { if (item.id === 'dashboard') {
title.style.display = hasVisible ? '' : 'none'; a.id = 'nav-dashboard';
a.classList.add('active');
a.onclick = function() { showDashboard(); };
} else {
a.setAttribute('data-url', item.url);
a.onclick = function() { loadTool(this, item.url); };
} }
a.innerHTML = '<i class="fas ' + item.icon + '"></i>' + item.name;
if (item.section === '概览') overviewSection.appendChild(a);
else toolsSection.appendChild(a);
}); });
} }
function openFeatModal() { function openFeatModal() {
var hidden = getHiddenFeatures(); var hidden = _hiddenFeatures;
var body = document.getElementById('featModalBody'); var body = document.getElementById('featModalBody');
var html = ''; var html = '';
var currentSection = ''; var currentSection = '';
FEATURES.forEach(function(f) { NAV_ITEMS.forEach(function(f) {
if (f.section !== currentSection) { if (f.section !== currentSection) {
currentSection = f.section; currentSection = f.section;
html += '<div class="feat-group-title">' + currentSection + '</div>'; html += '<div class="feat-group-title">' + currentSection + '</div>';
@@ -1415,15 +1338,31 @@
document.querySelectorAll('#featModalBody input[type="checkbox"]').forEach(function(cb) { document.querySelectorAll('#featModalBody input[type="checkbox"]').forEach(function(cb) {
if (!cb.checked) hidden.push(cb.getAttribute('data-feature-id')); if (!cb.checked) hidden.push(cb.getAttribute('data-feature-id'));
}); });
localStorage.setItem('hidden_features', JSON.stringify(hidden)); fetch('/api/sidebar-config', {
applyFeatSettings(); method: 'POST',
document.getElementById('featModalOverlay').classList.remove('show'); headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hidden_features: hidden})
})
.then(function(r) { return r.json(); })
.then(function() {
_hiddenFeatures = hidden;
buildSidebar();
document.getElementById('featModalOverlay').classList.remove('show');
});
} }
// 页面加载时应用功能显示设置 // 页面加载时从接口获取配置再构建侧边栏
applyFeatSettings(); fetch('/api/sidebar-config')
.then(function(r) { return r.json(); })
loadDashboard(); .then(function(data) {
_hiddenFeatures = data.hidden_features || [];
buildSidebar();
loadDashboard();
})
.catch(function() {
buildSidebar();
loadDashboard();
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,527 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>视频转音频</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; color: #333;
height: 100vh; overflow: hidden; display: flex;
}
.left-panel {
width: 460px; flex-shrink: 0;
display: flex; flex-direction: column;
border-right: 1px solid #e8e8e8;
background: #fff; height: 100vh; overflow-y: auto;
}
.left-panel::-webkit-scrollbar { width: 4px; }
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.input-section { padding: 20px 24px 28px; }
.header { display: flex; align-items: center; margin-bottom: 2px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; letter-spacing: -0.01em; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
.section-card {
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
}
.section-title {
font-size: 11px; font-weight: 600; color: #999;
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
}
.section-title i { font-size: 11px; }
.section-title .save-btn {
margin-left: auto; padding: 3px 10px; border: 1px solid #e0e0e0;
border-radius: 5px; background: #fff; color: #666; font-size: 11px;
font-weight: 600; cursor: pointer; transition: all 0.15s;
display: inline-flex; align-items: center; gap: 4px;
}
.section-title .save-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
.upload-area {
border: 2px dashed #e0e0e0; border-radius: 10px;
padding: 28px 16px; text-align: center; cursor: pointer;
transition: all 0.2s; background: #fff;
}
.upload-area:hover, .upload-area.dragover {
border-color: #0078d4; background: #f0f7ff;
}
.upload-area i { font-size: 28px; color: #ccc; margin-bottom: 8px; display: block; }
.upload-area .label { font-size: 13px; color: #999; }
.upload-area .filename { font-size: 13px; color: #0078d4; font-weight: 600; margin-top: 4px; }
.upload-area input[type="file"] { display: none; }
.file-info {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
margin-top: 8px;
}
.info-item {
background: #fff; border: 1px solid #f0f0f0; border-radius: 6px;
padding: 8px 10px; font-size: 12px;
}
.info-item .info-label { color: #999; font-size: 11px; }
.info-item .info-value { font-weight: 600; color: #333; margin-top: 2px; }
.input-group { margin-bottom: 10px; }
.input-group:last-child { margin-bottom: 0; }
.input-group label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555; }
.input-group select {
width: 100%; padding: 7px 10px;
border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #333; font-size: 13px;
outline: none; transition: all 0.15s ease;
}
.input-group select:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
.format-toggle {
display: flex; gap: 0; margin-bottom: 12px;
border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden;
}
.format-btn {
flex: 1; padding: 8px 0; text-align: center;
font-size: 13px; font-weight: 600; cursor: pointer;
background: #fff; color: #999; border: none;
transition: all 0.15s;
}
.format-btn.active {
background: #0078d4; color: #fff;
}
.format-btn:first-child { border-right: 1px solid #e0e0e0; }
.format-btn.active:first-child { border-right-color: #0078d4; }
.action-btn {
width: 100%; padding: 10px 18px;
border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-top: 14px;
background: linear-gradient(135deg, #0a7a1a, #065f12); color: #fff;
box-shadow: 0 2px 8px rgba(10,122,26,0.2);
}
.action-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(10,122,26,0.3); }
.action-btn:active:not(:disabled) { transform: translateY(0); }
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.right-panel {
flex: 1; background: #fff;
display: flex; flex-direction: column;
height: 100vh;
}
.right-header {
padding: 20px 24px 16px;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid #e8e8e8;
}
.right-header h2 { font-size: 15px; font-weight: 600; color: #333; }
.right-body {
flex: 1; overflow-y: auto; padding: 16px 24px 24px;
}
.progress-bar {
width: 100%; height: 6px; background: #f0f0f0; border-radius: 3px;
overflow: hidden; margin: 12px 0;
}
.progress-fill {
height: 100%; background: linear-gradient(90deg, #0078d4, #0a7a1a);
border-radius: 3px; transition: width 0.3s;
}
.status-text { font-size: 12px; color: #999; margin: 8px 0; }
.empty-state {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100%; color: #ccc;
}
.empty-state i { font-size: 48px; margin-bottom: 16px; }
.empty-state p { font-size: 14px; }
.result-card {
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 10px;
padding: 20px; text-align: center;
}
.result-card .result-icon {
width: 48px; height: 48px; border-radius: 12px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 20px; color: #fff; margin-bottom: 12px;
background: linear-gradient(135deg, #0a7a1a, #065f12);
}
.result-card .result-name { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 4px; }
.result-card .result-meta { font-size: 12px; color: #999; margin-bottom: 16px; }
.result-card audio {
width: 100%; margin-bottom: 16px; border-radius: 6px;
}
.result-actions { display: flex; gap: 8px; justify-content: center; }
.result-btn {
padding: 8px 20px; border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #666; font-size: 13px; font-weight: 600;
cursor: pointer; display: flex; align-items: center; gap: 6px;
transition: all 0.15s;
}
.result-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
.result-btn.primary { background: #0078d4; color: #fff; border-color: #0078d4; }
.result-btn.primary:hover { background: #006cbd; }
.warn-banner {
padding: 10px 14px; border-radius: 6px; font-size: 12px;
background: #fff3e0; color: #e65100; border: 1px solid #ffe0b2;
margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
}
.warn-banner i { font-size: 14px; }
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: slideIn 0.3s ease;
max-width: 360px;
}
.toast.success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
.toast.error { background: #fce4ec; color: #c62828; border: 1px solid #f8bbd0; }
.toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<div class="left-panel">
<div class="input-section">
<div class="header">
<h1><i class="fas fa-video" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>视频转音频</h1>
</div>
<p class="subtitle">从视频中提取音频,支持 MP4、MKV、AVI、WebM、MOV、FLV、WMV 格式</p>
{% if not ffmpeg_ok %}
<div class="warn-banner">
<i class="fas fa-info-circle"></i>
<span>未检测到 FFmpeg请先安装 FFmpeg 后再使用</span>
</div>
{% endif %}
<div class="section-card">
<div class="section-title"><i class="fas fa-file"></i> 上传视频</div>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('videoInput').click()">
<i class="fas fa-folder-open"></i>
<div class="label" id="uploadLabel">点击或拖拽视频文件到此处</div>
<div class="filename" id="uploadFilename" style="display:none;"></div>
<input type="file" id="videoInput" accept=".mp4,.mkv,.avi,.webm,.mov,.flv,.wmv">
</div>
<div class="file-info" id="fileInfo" style="display:none;">
<div class="info-item">
<div class="info-label">时长</div>
<div class="info-value" id="infoDur">-</div>
</div>
<div class="info-item">
<div class="info-label">文件大小</div>
<div class="info-value" id="infoSize">-</div>
</div>
<div class="info-item" style="grid-column:1/-1;">
<div class="info-label">文件名</div>
<div class="info-value" id="infoName" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">-</div>
</div>
</div>
</div>
<div class="section-card">
<div class="section-title">
<i class="fas fa-wrench"></i> 输出设置
<button class="save-btn" onclick="saveConfig()"><i class="fas fa-check"></i> 保存</button>
</div>
<div class="format-toggle" id="formatToggle">
<div class="format-btn active" data-format="mp3" onclick="setFormat('mp3')">MP3</div>
<div class="format-btn" data-format="wav" onclick="setFormat('wav')">WAV</div>
</div>
<div id="mp3Options">
<div class="input-group">
<label>比特率</label>
<select id="mp3Bitrate">
<option value="128k">128 kbps</option>
<option value="192k">192 kbps</option>
<option value="256k">256 kbps</option>
<option value="320k" selected>320 kbps</option>
</select>
</div>
</div>
<div id="wavOptions" style="display:none;">
<div class="input-group">
<label>采样率</label>
<select id="wavSampleRate">
<option value="original">保持原始</option>
<option value="44100">44100 Hz</option>
<option value="48000">48000 Hz</option>
</select>
</div>
</div>
</div>
<button class="action-btn" id="btnConvert" onclick="doConvert()" disabled>
<i class="fas fa-play"></i> 开始转换
</button>
</div>
</div>
<div class="right-panel">
<div class="right-header">
<h2><i class="fas fa-exchange-alt" style="color:#999;margin-right:6px;"></i>转换结果</h2>
</div>
<div class="right-body" id="rightBody">
<div class="empty-state" id="emptyState">
<i class="fas fa-video"></i>
<p>上传视频文件后开始转换</p>
</div>
<div id="progressSection" style="display:none;">
<div class="status-text" id="statusText">正在转换...</div>
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%;"></div></div>
</div>
<div id="resultSection" style="display:none;">
<div class="result-card">
<div class="result-icon"><i class="fas fa-check"></i></div>
<div class="result-name" id="resultName"></div>
<div class="result-meta" id="resultMeta"></div>
<audio id="audioPlayer" controls style="width:100%;"></audio>
<div class="result-actions" style="margin-top:12px;">
<button class="result-btn primary" onclick="doDownload()">
<i class="fas fa-folder-plus"></i> 下载文件
</button>
<button class="result-btn" onclick="doCleanup()">
<i class="fas fa-trash"></i> 清理
</button>
</div>
</div>
</div>
</div>
</div>
<script>
var taskId = null;
var pollTimer = null;
var currentFormat = '{{ config.output_format|default("mp3") }}';
function showMessage(text, type, duration) {
type = type || 'info'; duration = duration || 3000;
var t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = text;
document.getElementById('toastContainer').appendChild(t);
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function(){ t.remove(); }, 300); }, duration);
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB';
}
function formatDur(sec) {
if (sec < 60) return sec.toFixed(1) + 's';
var m = Math.floor(sec / 60);
var s = (sec % 60).toFixed(1);
return m + 'm ' + s + 's';
}
function setFormat(fmt) {
currentFormat = fmt;
document.querySelectorAll('.format-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.format === fmt);
});
document.getElementById('mp3Options').style.display = fmt === 'mp3' ? 'block' : 'none';
document.getElementById('wavOptions').style.display = fmt === 'wav' ? 'block' : 'none';
}
function getSettings() {
return {
output_format: currentFormat,
mp3_bitrate: document.getElementById('mp3Bitrate').value,
wav_sample_rate: document.getElementById('wavSampleRate').value,
};
}
async function saveConfig() {
try {
var res = await fetch('/mp4-to-audio/config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(getSettings())
});
var data = await res.json();
if (data.success) showMessage('设置已保存', 'success');
} catch (e) {
showMessage('保存失败: ' + e.message, 'error');
}
}
// 恢复配置
(function() {
var cfg = {
output_format: '{{ config.output_format|default("mp3") }}',
mp3_bitrate: '{{ config.mp3_bitrate|default("320k") }}',
wav_sample_rate: '{{ config.wav_sample_rate|default("original") }}',
};
setFormat(cfg.output_format);
document.getElementById('mp3Bitrate').value = cfg.mp3_bitrate;
document.getElementById('wavSampleRate').value = cfg.wav_sample_rate;
})();
// 上传区
var uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', function() { this.classList.remove('dragover'); });
uploadArea.addEventListener('drop', function(e) {
e.preventDefault(); this.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) uploadFile(e.dataTransfer.files[0]);
});
document.getElementById('videoInput').addEventListener('change', function() {
if (this.files.length > 0) uploadFile(this.files[0]);
});
async function uploadFile(file) {
var fd = new FormData();
fd.append('video', file);
document.getElementById('uploadLabel').textContent = '上传中...';
document.getElementById('uploadFilename').style.display = 'none';
document.getElementById('fileInfo').style.display = 'none';
document.getElementById('btnConvert').disabled = true;
try {
var res = await fetch('/mp4-to-audio/upload', { method: 'POST', body: fd });
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
document.getElementById('uploadLabel').textContent = '点击或拖拽视频文件到此处';
return;
}
taskId = data.task_id;
document.getElementById('uploadLabel').textContent = '已选择文件';
document.getElementById('uploadFilename').textContent = data.filename;
document.getElementById('uploadFilename').style.display = 'block';
document.getElementById('fileInfo').style.display = 'grid';
document.getElementById('infoDur').textContent = data.duration > 0 ? formatDur(data.duration) : '-';
document.getElementById('infoSize').textContent = formatSize(data.size);
document.getElementById('infoName').textContent = data.filename;
document.getElementById('btnConvert').disabled = false;
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('progressSection').style.display = 'none';
document.getElementById('resultSection').style.display = 'none';
} catch (e) {
showMessage('上传失败: ' + e.message, 'error');
document.getElementById('uploadLabel').textContent = '点击或拖拽视频文件到此处';
}
}
async function doConvert() {
if (!taskId) return;
var btn = document.getElementById('btnConvert');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 转换中...';
document.getElementById('emptyState').style.display = 'none';
document.getElementById('resultSection').style.display = 'none';
document.getElementById('progressSection').style.display = 'block';
document.getElementById('progressFill').style.width = '0%';
document.getElementById('statusText').textContent = '正在转换...';
var settings = getSettings();
settings.task_id = taskId;
try {
var res = await fetch('/mp4-to-audio/convert', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(settings)
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
document.getElementById('progressSection').style.display = 'none';
document.getElementById('emptyState').style.display = 'flex';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play"></i> 开始转换';
return;
}
startPoll();
} catch (e) {
showMessage('请求失败: ' + e.message, 'error');
document.getElementById('progressSection').style.display = 'none';
document.getElementById('emptyState').style.display = 'flex';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play"></i> 开始转换';
}
}
function startPoll() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async function() {
try {
var res = await fetch('/mp4-to-audio/status/' + taskId);
var data = await res.json();
if (!data.success) return;
document.getElementById('progressFill').style.width = data.progress + '%';
document.getElementById('statusText').textContent = '转换进度: ' + data.progress + '%';
if (data.status === 'done') {
clearInterval(pollTimer); pollTimer = null;
document.getElementById('progressSection').style.display = 'none';
document.getElementById('resultSection').style.display = 'block';
document.getElementById('resultName').textContent = data.filename;
var fmt = data.output_format === 'mp3' ? 'MP3' : 'WAV';
document.getElementById('resultMeta').textContent = '格式: ' + fmt;
document.getElementById('audioPlayer').src = '/mp4-to-audio/play/' + taskId;
var btn = document.getElementById('btnConvert');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play"></i> 开始转换';
} else if (data.status === 'error') {
clearInterval(pollTimer); pollTimer = null;
showMessage('转换失败: ' + data.error, 'error', 5000);
document.getElementById('progressSection').style.display = 'none';
document.getElementById('emptyState').style.display = 'flex';
var btn = document.getElementById('btnConvert');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play"></i> 开始转换';
}
} catch (e) {}
}, 500);
}
function doDownload() {
if (!taskId) return;
var a = document.createElement('a');
a.href = '/mp4-to-audio/download/' + taskId;
a.download = '';
a.click();
}
async function doCleanup() {
if (!taskId) return;
try {
await fetch('/mp4-to-audio/cleanup/' + taskId, { method: 'POST' });
showMessage('已清理临时文件', 'success');
document.getElementById('resultSection').style.display = 'none';
document.getElementById('emptyState').style.display = 'flex';
var player = document.getElementById('audioPlayer');
player.pause(); player.src = '';
taskId = null;
} catch (e) {}
}
</script>
</body>
</html>

View File

@@ -93,7 +93,7 @@
} }
.slider-value { font-weight: 600; color: #0078d4; font-size: 12px; } .slider-value { font-weight: 600; color: #0078d4; font-size: 12px; }
input[type="range"] { input[type="range"] {
width: 100%; height: 4px; -webkit-appearance: none; appearance: none; width: 50%; height: 4px; -webkit-appearance: none; appearance: none;
background: #e0e0e0; border-radius: 2px; outline: none; background: #e0e0e0; border-radius: 2px; outline: none;
} }
input[type="range"]::-webkit-slider-thumb { input[type="range"]::-webkit-slider-thumb {
@@ -212,9 +212,34 @@
} }
.right-header h2 { font-size: 15px; font-weight: 600; color: #333; } .right-header h2 { font-size: 15px; font-weight: 600; color: #333; }
.right-body { .right-body {
flex: 1; overflow-y: auto; padding: 16px 24px 24px; flex: 1; overflow-y: auto; padding: 16px 24px 24px; min-height: 0;
} }
/* 日志区域 */
.log-area {
flex: 1; flex-shrink: 0; display: flex; flex-direction: column;
background: #1e1e1e; color: #d4d4d4; border-top: 1px solid #3e3e3e;
min-height: 0;
}
.log-header {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px; background: #2d2d2d; border-bottom: 1px solid #3e3e3e;
font-size: 11px; color: #888; flex-shrink: 0;
}
.log-header i { margin-right: 4px; }
.log-clear-btn {
background: none; border: 1px solid #555; color: #999;
border-radius: 4px; padding: 2px 8px; font-size: 10px;
cursor: pointer; transition: all 0.15s;
}
.log-clear-btn:hover { background: #3e3e3e; color: #ccc; border-color: #777; }
.log-content {
flex: 1; overflow-y: auto; padding: 6px 10px;
font-size: 11px; font-family: Consolas, "Courier New", monospace; line-height: 1.6;
}
.log-content::-webkit-scrollbar { width: 4px; }
.log-content::-webkit-scrollbar-thumb { background: #555; border-radius: 2px; }
/* 进度条 */ /* 进度条 */
.progress-bar { .progress-bar {
width: 100%; height: 8px; background: #f0f0f0; border-radius: 4px; width: 100%; height: 8px; background: #f0f0f0; border-radius: 4px;
@@ -248,8 +273,8 @@
.stem-icon.vocals { background: linear-gradient(135deg, #0078d4, #005fa3); } .stem-icon.vocals { background: linear-gradient(135deg, #0078d4, #005fa3); }
.stem-icon.instrumental { background: linear-gradient(135deg, #0a7a1a, #065f12); } .stem-icon.instrumental { background: linear-gradient(135deg, #0a7a1a, #065f12); }
.stem-icon.other { background: linear-gradient(135deg, #f57c00, #e65100); } .stem-icon.other { background: linear-gradient(135deg, #f57c00, #e65100); }
.stem-info { flex: 1; } .stem-info { width: 180px; flex-shrink: 0; }
.stem-name { font-size: 14px; font-weight: 600; color: #333; } .stem-name { font-size: 14px; font-weight: 600; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stem-filename { font-size: 11px; color: #999; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .stem-filename { font-size: 11px; color: #999; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stem-actions { display: flex; gap: 6px; } .stem-actions { display: flex; gap: 6px; }
.stem-btn { .stem-btn {
@@ -261,6 +286,35 @@
.stem-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; } .stem-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
.stem-btn.play-btn:hover { color: #0078d4; border-color: #0078d4; } .stem-btn.play-btn:hover { color: #0078d4; border-color: #0078d4; }
/* 音频播放器 */
.stem-player {
display: none; align-items: center; gap: 8px;
flex: 1; min-width: 0;
}
.stem-player.active { display: flex; }
.player-btn {
width: 26px; height: 26px; border-radius: 50%; border: none;
background: #0078d4; color: #fff; cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 10px;
flex-shrink: 0; transition: all 0.15s;
}
.player-btn:hover { background: #006cbd; }
.player-btn.playing { background: #e53935; }
.player-btn.playing:hover { background: #c62828; }
.player-progress {
width: 200px; flex-shrink: 0; height: 4px;
-webkit-appearance: none; appearance: none;
background: #e0e0e0; border-radius: 2px; outline: none; cursor: pointer;
}
.player-progress::-webkit-slider-thumb {
-webkit-appearance: none; width: 12px; height: 12px;
background: #0078d4; border-radius: 50%; cursor: pointer;
}
.player-time {
font-size: 11px; color: #999; flex-shrink: 0;
font-variant-numeric: tabular-nums; min-width: 70px; text-align: right;
}
/* 顶部操作栏 */ /* 顶部操作栏 */
.top-actions { .top-actions {
display: flex; gap: 8px; margin-bottom: 16px; display: flex; gap: 8px; margin-bottom: 16px;
@@ -368,7 +422,7 @@
<div class="input-group"> <div class="input-group">
<label>选择模型</label> <label>选择模型</label>
<select id="modelSelect" style="width:100%;padding:8px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;transition:all 0.15s ease;"> <select id="modelSelect" onchange="onModelSelect(this)" style="width:100%;padding:8px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;transition:all 0.15s ease;">
<option value="">-- 请先扫描模型目录 --</option> <option value="">-- 请先扫描模型目录 --</option>
</select> </select>
</div> </div>
@@ -399,17 +453,16 @@
</div> </div>
<div class="input-group"> <div class="input-group">
<label>Segment分割参数大小</label> <label>Segment分割参数大小</label>
<input type="text" id="demucsSegment" placeholder="如 Default、1、2、4" <select id="demucsSegment" onchange="toggleCustom(this,'demucsSegmentCustom')" style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
list="demucsSegmentOptions" <option value="Default">Default</option>
style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;"> <option value="1">1</option><option value="2">2</option><option value="3">3</option>
<datalist id="demucsSegmentOptions"> <option value="4">4</option><option value="5">5</option><option value="6">6</option>
<option value="Default"> <option value="7">7</option><option value="8">8</option><option value="10">10</option>
<option value="1"><option value="2"><option value="3"> <option value="12">12</option><option value="15">15</option><option value="20">20</option>
<option value="4"><option value="5"><option value="6"> <option value="30">30</option><option value="40">40</option><option value="50">50</option>
<option value="7"><option value="8"><option value="10"> <option value="__custom__">自定义...</option>
<option value="12"><option value="15"><option value="20"> </select>
<option value="30"><option value="40"><option value="50"> <input type="text" id="demucsSegmentCustom" placeholder="输入自定义值" style="display:none;margin-top:4px;width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
</datalist>
<div class="param-desc">较小的尺寸占用资源较少。较大的尺寸效果可能更好。</div> <div class="param-desc">较小的尺寸占用资源较少。较大的尺寸效果可能更好。</div>
</div> </div>
</div> </div>
@@ -419,24 +472,25 @@
<div class="arch-params-label"><i class="fas fa-cog"></i> MDX-Net 参数</div> <div class="arch-params-label"><i class="fas fa-cog"></i> MDX-Net 参数</div>
<div class="input-group"> <div class="input-group">
<label>Segment分割参数大小</label> <label>Segment分割参数大小</label>
<input type="text" id="mdxSegmentSize" placeholder="如 Default、256、512、1024" <select id="mdxSegmentSize" onchange="toggleCustom(this,'mdxSegmentSizeCustom')" style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
list="mdxSegmentOptions" <option value="Default">Default</option>
style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;"> <option value="128">128</option><option value="256">256</option>
<datalist id="mdxSegmentOptions"> <option value="512">512</option><option value="1024">1024</option>
<option value="Default"> <option value="2048">2048</option><option value="4096">4096</option>
<option value="128"><option value="256"><option value="512"> <option value="__custom__">自定义...</option>
<option value="1024"><option value="2048"><option value="4096"> </select>
</datalist> <input type="text" id="mdxSegmentSizeCustom" placeholder="输入自定义值" style="display:none;margin-top:4px;width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
<div class="param-desc">默认大小为 256。音质可能会因你的选择而有所不同。</div> <div class="param-desc">默认大小为 256。音质可能会因你的选择而有所不同。</div>
</div> </div>
<div class="input-group"> <div class="input-group">
<label>Overlap重叠度</label> <label>Overlap重叠度</label>
<input type="text" id="mdxOverlap" placeholder="如 0.250(范围 0.10 ~ 0.99" <select id="mdxOverlap" onchange="toggleCustom(this,'mdxOverlapCustom')" style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
list="mdxOverlapOptions" <option value="0.100">0.100</option><option value="0.250">0.250</option>
style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;"> <option value="0.500">0.500</option><option value="0.750">0.750</option>
<datalist id="mdxOverlapOptions"> <option value="0.990">0.990</option>
<option value="0.100"><option value="0.250"><option value="0.500"><option value="0.750"><option value="0.990"> <option value="__custom__">自定义...</option>
</datalist> </select>
<input type="text" id="mdxOverlapCustom" placeholder="输入自定义值,如 0.350" style="display:none;margin-top:4px;width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
<div class="param-desc">较高的值可能会带来更好的效果,但也会导致处理时间延长。</div> <div class="param-desc">较高的值可能会带来更好的效果,但也会导致处理时间延长。</div>
</div> </div>
</div> </div>
@@ -446,22 +500,28 @@
<div class="arch-params-label"><i class="fas fa-cog"></i> VR Arc 参数</div> <div class="arch-params-label"><i class="fas fa-cog"></i> VR Arc 参数</div>
<div class="input-group"> <div class="input-group">
<label>Window Size窗口大小</label> <label>Window Size窗口大小</label>
<input type="text" id="vrWindowSize" placeholder="如 1024、512、320" <select id="vrWindowSize" onchange="toggleCustom(this,'vrWindowSizeCustom')" style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
list="vrWindowSizeOptions" <option value="320">320</option>
style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;"> <option value="512">512</option>
<datalist id="vrWindowSizeOptions"> <option value="1024">1024</option>
<option value="1024"><option value="512"><option value="320"> <option value="2048">2048</option>
</datalist> <option value="4096">4096</option>
<option value="__custom__">自定义...</option>
</select>
<input type="text" id="vrWindowSizeCustom" placeholder="输入自定义值" style="display:none;margin-top:4px;width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
<div class="param-desc">选择窗口大小,以平衡音质与处理速度。</div> <div class="param-desc">选择窗口大小,以平衡音质与处理速度。</div>
</div> </div>
<div class="input-group"> <div class="input-group">
<label>Aggression分离强度</label> <label>Aggression分离强度</label>
<input type="text" id="vrAggression" placeholder="如 5范围 -100 ~ 100" <select id="vrAggression" onchange="toggleCustom(this,'vrAggressionCustom')" style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
list="vrAggressionOptions" <option value="-100">-100</option><option value="-50">-50</option>
style="width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;"> <option value="-10">-10</option><option value="-5">-5</option>
<datalist id="vrAggressionOptions"> <option value="0">0</option><option value="5">5</option>
<option value="5"><option value="10"><option value="0"><option value="-5"><option value="100"><option value="-100"> <option value="10">10</option><option value="50">50</option>
</datalist> <option value="100">100</option>
<option value="__custom__">自定义...</option>
</select>
<input type="text" id="vrAggressionCustom" placeholder="输入自定义值,如 20" style="display:none;margin-top:4px;width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;background:#fff;color:#333;outline:none;">
<div class="param-desc">数值越大,提取越深入。对于人声和伴奏,通常设置为 5。</div> <div class="param-desc">数值越大,提取越深入。对于人声和伴奏,通常设置为 5。</div>
</div> </div>
</div> </div>
@@ -540,6 +600,13 @@
<div id="stemsList"></div> <div id="stemsList"></div>
</div> </div>
</div> </div>
<div class="log-area" id="logArea">
<div class="log-header">
<span><i class="fas fa-code"></i> 控制台日志</span>
<button class="log-clear-btn" onclick="clearLogs()" title="清除日志">清除</button>
</div>
<div class="log-content" id="logContent"></div>
</div>
</div> </div>
<!-- 设置弹窗 --> <!-- 设置弹窗 -->
@@ -605,6 +672,37 @@
var currentArch = 'Demucs'; var currentArch = 'Demucs';
var scannedModels = []; var scannedModels = [];
// ── 自定义输入切换 ──────────────────────────────────────────────────────
function toggleCustom(sel, customId) {
var customEl = document.getElementById(customId);
if (!customEl) return;
customEl.style.display = sel.value === '__custom__' ? 'block' : 'none';
if (sel.value === '__custom__') customEl.focus();
}
function getSelectValue(selectId, customId) {
var sel = document.getElementById(selectId);
if (!sel) return '';
if (sel.value === '__custom__') {
var custom = document.getElementById(customId);
return custom ? custom.value.trim() : '';
}
return sel.value;
}
function restoreCustomSelect(selectId, customId, value) {
var sel = document.getElementById(selectId);
if (!sel) return;
// Try matching a preset option
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === value) { sel.selectedIndex = i; return; }
}
// No match — use custom
sel.value = '__custom__';
var custom = document.getElementById(customId);
if (custom) { custom.style.display = 'block'; custom.value = value; }
}
// ── 设置弹窗 ────────────────────────────────────────────────────────── // ── 设置弹窗 ──────────────────────────────────────────────────────────
function openSettings() { function openSettings() {
var dirs = window._savedModelDirs || {}; var dirs = window._savedModelDirs || {};
@@ -893,19 +991,14 @@
// 架构特异参数 // 架构特异参数
if (currentArch === 'Demucs') { if (currentArch === 'Demucs') {
var demucsStemSelect = document.getElementById('demucsStemSelect'); var demucsStemSelect = document.getElementById('demucsStemSelect');
var demucsSegment = document.getElementById('demucsSegment');
formData.append('demucs_stems', demucsStemSelect ? demucsStemSelect.value : 'All Stems'); formData.append('demucs_stems', demucsStemSelect ? demucsStemSelect.value : 'All Stems');
formData.append('segment', demucsSegment ? demucsSegment.value : 'Default'); formData.append('segment', getSelectValue('demucsSegment', 'demucsSegmentCustom') || 'Default');
} else if (currentArch === 'MDX-Net') { } else if (currentArch === 'MDX-Net') {
var mdxSegmentSize = document.getElementById('mdxSegmentSize'); formData.append('segment', getSelectValue('mdxSegmentSize', 'mdxSegmentSizeCustom') || 'Default');
var mdxOverlap = document.getElementById('mdxOverlap'); formData.append('mdx_overlap', getSelectValue('mdxOverlap', 'mdxOverlapCustom') || '0.250');
formData.append('segment', mdxSegmentSize ? mdxSegmentSize.value : 'Default');
formData.append('mdx_overlap', mdxOverlap ? mdxOverlap.value : '0.250');
} else if (currentArch === 'VR Arc') { } else if (currentArch === 'VR Arc') {
var vrWindowSize = document.getElementById('vrWindowSize'); formData.append('vr_window_size', getSelectValue('vrWindowSize', 'vrWindowSizeCustom') || '1024');
var vrAggression = document.getElementById('vrAggression'); formData.append('vr_aggression', getSelectValue('vrAggression', 'vrAggressionCustom') || '5');
formData.append('vr_window_size', vrWindowSize ? vrWindowSize.value : '1024');
formData.append('vr_aggression', vrAggression ? vrAggression.value : '5');
} }
var startBtn = document.getElementById('startBtn'); var startBtn = document.getElementById('startBtn');
@@ -921,6 +1014,8 @@
if (progressSection) progressSection.style.display = 'block'; if (progressSection) progressSection.style.display = 'block';
if (progressFill) progressFill.style.width = '0%'; if (progressFill) progressFill.style.width = '0%';
if (statusText) statusText.textContent = '正在上传并初始化...'; if (statusText) statusText.textContent = '正在上传并初始化...';
var logContent = document.getElementById('logContent');
if (logContent) logContent.innerHTML = '';
fetch('/uvr-sep/separate', { method: 'POST', body: formData }) fetch('/uvr-sep/separate', { method: 'POST', body: formData })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@@ -944,6 +1039,22 @@
}); });
} }
// ── 日志 ──────────────────────────────────────────────────────────────
function renderLogs(logs) {
var el = document.getElementById('logContent');
if (!el || !logs || !logs.length) return;
el.innerHTML = logs.map(function(l) { return l.replace(/</g, '&lt;'); }).join('<br>');
el.scrollTop = el.scrollHeight;
}
function clearLogs() {
var el = document.getElementById('logContent');
if (el) el.innerHTML = '';
if (currentTaskId) {
fetch('/uvr-sep/clear-logs/' + currentTaskId, { method: 'POST' });
}
}
// ── 轮询状态 ────────────────────────────────────────────────────────── // ── 轮询状态 ──────────────────────────────────────────────────────────
function pollStatus() { function pollStatus() {
if (!currentTaskId) return; if (!currentTaskId) return;
@@ -960,6 +1071,7 @@
var emptyState = document.getElementById('emptyState'); var emptyState = document.getElementById('emptyState');
if (progressFill) progressFill.style.width = data.progress + '%'; if (progressFill) progressFill.style.width = data.progress + '%';
if (data.logs) renderLogs(data.logs);
var devInfo = data.device ? ' [' + data.device + ']' : ''; var devInfo = data.device ? ' [' + data.device + ']' : '';
if (data.status === 'done') { if (data.status === 'done') {
clearInterval(pollTimer); pollTimer = null; clearInterval(pollTimer); pollTimer = null;
@@ -990,14 +1102,25 @@
var name = s.stem.toLowerCase(); var name = s.stem.toLowerCase();
if (name === 'vocals' || name === 'vocal') { iconClass = 'vocals'; icon = 'fa-microphone'; } if (name === 'vocals' || name === 'vocal') { iconClass = 'vocals'; icon = 'fa-microphone'; }
else if (name === 'instrumental' || name === 'no vocals') { iconClass = 'instrumental'; icon = 'fa-folder'; } else if (name === 'instrumental' || name === 'no vocals') { iconClass = 'instrumental'; icon = 'fa-folder'; }
var safeStem = s.stem.replace(/'/g, "\\'");
var row = document.createElement('div'); var row = document.createElement('div');
row.className = 'stem-row'; row.className = 'stem-row';
row.innerHTML = row.innerHTML =
'<div class="stem-icon ' + iconClass + '"><i class="fas ' + icon + '"></i></div>' + '<div class="stem-icon ' + iconClass + '"><i class="fas ' + icon + '"></i></div>' +
'<div class="stem-info"><div class="stem-name">' + s.stem + '</div><div class="stem-filename">' + s.filename + '</div></div>' + '<div class="stem-info"><div class="stem-name">' + s.stem + '</div><div class="stem-filename">' + s.filename + '</div></div>' +
'<div class="stem-actions">' + '<div style="display:flex;align-items:center;gap:10px;margin-left:auto;">' +
'<button class="stem-btn play-btn" onclick="playStem(\'' + currentTaskId + '\',\'' + s.stem + '\')"><i class="fas fa-play"></i></button>' + '<div class="stem-player active" id="player_' + safeStem + '">' +
'<button class="stem-btn" onclick="downloadStem(\'' + currentTaskId + '\',\'' + s.stem + '\')"><i class="fas fa-folder-plus"></i></button>' + '<button class="player-btn" id="playerBtn_' + safeStem + '" onclick="playStem(\'' + currentTaskId + '\',\'' + safeStem + '\')" title="播放"><i class="fas fa-play"></i></button>' +
'<input type="range" class="player-progress" id="progress_' + safeStem + '" min="0" max="100" value="0" step="0.1"' +
' onmousedown="startSeeking(\'' + safeStem + '\')"' +
' onmouseup="doSeek(\'' + safeStem + '\')"' +
' ontouchstart="startSeeking(\'' + safeStem + '\')"' +
' ontouchend="doSeek(\'' + safeStem + '\')">' +
'<span class="player-time" id="time_' + safeStem + '">0:00 / 0:00</span>' +
'</div>' +
'<div class="stem-actions">' +
'<button class="stem-btn" onclick="downloadStem(\'' + currentTaskId + '\',\'' + safeStem + '\')" title="下载"><i class="fas fa-folder-plus"></i></button>' +
'</div>' +
'</div>'; '</div>';
list.appendChild(row); list.appendChild(row);
}); });
@@ -1007,12 +1130,115 @@
if (resultSection) resultSection.style.display = 'block'; if (resultSection) resultSection.style.display = 'block';
} }
// ── 音频播放控制 ─────────────────────────────────────────────────────
var currentStem = '';
var playTimer = null;
var seeking = false;
function playStem(taskId, stem) { function playStem(taskId, stem) {
var audio = document.getElementById('previewAudio'); var audio = document.getElementById('previewAudio');
if (audio) { if (!audio) return;
// 切换音轨
if (currentStem !== stem) {
stopCurrentStem();
currentStem = stem;
audio.src = '/uvr-sep/download/' + encodeURIComponent(taskId) + '/' + encodeURIComponent(stem); audio.src = '/uvr-sep/download/' + encodeURIComponent(taskId) + '/' + encodeURIComponent(stem);
audio.play();
} }
// 播放/暂停切换
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
}
function stopCurrentStem() {
var audio = document.getElementById('previewAudio');
if (!audio) return;
audio.pause();
audio.removeAttribute('src');
if (playTimer) { clearInterval(playTimer); playTimer = null; }
if (currentStem) {
var btn = document.getElementById('playerBtn_' + currentStem);
var prog = document.getElementById('progress_' + currentStem);
var time = document.getElementById('time_' + currentStem);
if (btn) { btn.className = 'player-btn'; btn.innerHTML = '<i class="fas fa-play"></i>'; }
if (prog) prog.value = 0;
if (time) time.textContent = '0:00 / 0:00';
}
currentStem = '';
}
// audio 事件
(function() {
var audio = document.getElementById('previewAudio');
if (!audio) return;
audio.addEventListener('play', function() {
if (!currentStem) return;
var btn = document.getElementById('playerBtn_' + currentStem);
if (btn) { btn.className = 'player-btn playing'; btn.innerHTML = '<i class="fas fa-pause"></i>'; }
if (playTimer) clearInterval(playTimer);
playTimer = setInterval(updatePlayProgress, 200);
});
audio.addEventListener('pause', function() {
if (!currentStem) return;
var btn = document.getElementById('playerBtn_' + currentStem);
if (btn) { btn.className = 'player-btn'; btn.innerHTML = '<i class="fas fa-play"></i>'; }
if (playTimer) { clearInterval(playTimer); playTimer = null; }
});
audio.addEventListener('ended', function() {
if (!currentStem) return;
var btn = document.getElementById('playerBtn_' + currentStem);
var prog = document.getElementById('progress_' + currentStem);
var time = document.getElementById('time_' + currentStem);
if (btn) { btn.className = 'player-btn'; btn.innerHTML = '<i class="fas fa-play"></i>'; }
if (prog) prog.value = 100;
if (time) time.textContent = formatPlayerTime(audio.duration) + ' / ' + formatPlayerTime(audio.duration);
if (playTimer) { clearInterval(playTimer); playTimer = null; }
});
audio.addEventListener('loadedmetadata', function() {
updatePlayProgress();
});
})();
function updatePlayProgress() {
var audio = document.getElementById('previewAudio');
if (!audio || !currentStem || isNaN(audio.duration)) return;
var prog = document.getElementById('progress_' + currentStem);
var time = document.getElementById('time_' + currentStem);
if (prog && !seeking) {
prog.value = audio.currentTime / audio.duration * 100;
}
if (time) {
time.textContent = formatPlayerTime(audio.currentTime) + ' / ' + formatPlayerTime(audio.duration);
}
}
function startSeeking(stem) {
if (stem === currentStem) seeking = true;
}
function doSeek(stem) {
seeking = false;
var audio = document.getElementById('previewAudio');
if (!audio || stem !== currentStem || isNaN(audio.duration)) return;
var prog = document.getElementById('progress_' + stem);
if (prog) {
audio.currentTime = parseFloat(prog.value) / 100 * audio.duration;
}
}
function formatPlayerTime(sec) {
if (isNaN(sec)) return '0:00';
var m = Math.floor(sec / 60);
var s = Math.floor(sec % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
} }
function downloadStem(taskId, stem) { function downloadStem(taskId, stem) {
@@ -1048,11 +1274,6 @@
var isSecondaryOnly = document.getElementById('isSecondaryOnly'); var isSecondaryOnly = document.getElementById('isSecondaryOnly');
var saveFormat = document.getElementById('saveFormat'); var saveFormat = document.getElementById('saveFormat');
var demucsStemSelect = document.getElementById('demucsStemSelect'); var demucsStemSelect = document.getElementById('demucsStemSelect');
var demucsSegment = document.getElementById('demucsSegment');
var mdxSegmentSize = document.getElementById('mdxSegmentSize');
var mdxOverlap = document.getElementById('mdxOverlap');
var vrWindowSize = document.getElementById('vrWindowSize');
var vrAggression = document.getElementById('vrAggression');
var cfg = { var cfg = {
uvr_project_path: uvrPath ? uvrPath.value.trim() : '', uvr_project_path: uvrPath ? uvrPath.value.trim() : '',
@@ -1069,11 +1290,11 @@
is_secondary_stem_only: isSecondaryOnly ? isSecondaryOnly.checked : false, is_secondary_stem_only: isSecondaryOnly ? isSecondaryOnly.checked : false,
save_format: saveFormat ? saveFormat.value : 'wav', save_format: saveFormat ? saveFormat.value : 'wav',
demucs_stems: demucsStemSelect ? demucsStemSelect.value : 'All Stems', demucs_stems: demucsStemSelect ? demucsStemSelect.value : 'All Stems',
demucs_segment: demucsSegment ? demucsSegment.value : 'Default', demucs_segment: getSelectValue('demucsSegment', 'demucsSegmentCustom') || 'Default',
mdx_segment_size: mdxSegmentSize ? mdxSegmentSize.value : 'Default', mdx_segment_size: getSelectValue('mdxSegmentSize', 'mdxSegmentSizeCustom') || 'Default',
mdx_overlap: mdxOverlap ? parseFloat(mdxOverlap.value) : 0.25, mdx_overlap: parseFloat(getSelectValue('mdxOverlap', 'mdxOverlapCustom')) || 0.25,
vr_window_size: vrWindowSize ? parseInt(vrWindowSize.value) : 1024, vr_window_size: parseInt(getSelectValue('vrWindowSize', 'vrWindowSizeCustom')) || 1024,
vr_aggression: vrAggression ? parseInt(vrAggression.value) : 5, vr_aggression: parseInt(getSelectValue('vrAggression', 'vrAggressionCustom')) || 5,
}; };
// 保存选中模型(按架构区分) // 保存选中模型(按架构区分)
@@ -1190,20 +1411,15 @@
if (demucsStemSel.options[i].value === cfg.demucs_stems) { demucsStemSel.selectedIndex = i; break; } if (demucsStemSel.options[i].value === cfg.demucs_stems) { demucsStemSel.selectedIndex = i; break; }
} }
} }
var demucsSegSel = document.getElementById('demucsSegment'); restoreCustomSelect('demucsSegment', 'demucsSegmentCustom', cfg.demucs_segment);
if (demucsSegSel) demucsSegSel.value = cfg.demucs_segment;
// 恢复 MDX 参数 // 恢复 MDX 参数
var mdxSegSel = document.getElementById('mdxSegmentSize'); restoreCustomSelect('mdxSegmentSize', 'mdxSegmentSizeCustom', cfg.mdx_segment_size);
if (mdxSegSel) mdxSegSel.value = cfg.mdx_segment_size; restoreCustomSelect('mdxOverlap', 'mdxOverlapCustom', cfg.mdx_overlap.toFixed(3));
var mdxOverlap = document.getElementById('mdxOverlap');
if (mdxOverlap) mdxOverlap.value = cfg.mdx_overlap.toFixed(3);
// 恢复 VR 参数 // 恢复 VR 参数
var vrWinSel = document.getElementById('vrWindowSize'); restoreCustomSelect('vrWindowSize', 'vrWindowSizeCustom', String(cfg.vr_window_size));
if (vrWinSel) vrWinSel.value = cfg.vr_window_size; restoreCustomSelect('vrAggression', 'vrAggressionCustom', String(cfg.vr_aggression));
var vrAggression = document.getElementById('vrAggression');
if (vrAggression) vrAggression.value = cfg.vr_aggression;
// 恢复通用参数 // 恢复通用参数
var isGpu = document.getElementById('isGpu'); var isGpu = document.getElementById('isGpu');