generated from dellevin/template
feat: complete local version to overwrite remote
This commit is contained in:
22
flask-dev-api/blueprints/__init__.py
Normal file
22
flask-dev-api/blueprints/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# blueprints package
|
||||
from .pin_tu import bp as pin_tu_bp
|
||||
from .base64_codec import bp as base64_bp
|
||||
from .down_video import bp as down_video_bp
|
||||
from .fen_ci import bp as fen_ci_bp
|
||||
from .content_tag import bp as content_tag_bp
|
||||
from .chmod_calc import bp as chmod_calc_bp
|
||||
from .json_format import bp as json_format_bp
|
||||
from .qr_code import bp as qr_code_bp
|
||||
from .http_status import bp as http_status_bp
|
||||
from .url_parser import bp as url_parser_bp
|
||||
from .token_gen import bp as token_gen_bp
|
||||
from .sovits_tts import bp as sovits_tts_bp
|
||||
from .stt import bp as stt_bp
|
||||
from .ai_dubbing import bp as ai_dubbing_bp
|
||||
from .rvc import bp as rvc_bp
|
||||
|
||||
__all__ = [
|
||||
'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',
|
||||
]
|
||||
BIN
flask-dev-api/blueprints/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/ai_dubbing.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/ai_dubbing.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/chmod_calc.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/chmod_calc.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/content_tag.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/content_tag.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/down_image.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/down_image.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/down_video.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/down_video.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/fen_ci.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/fen_ci.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/http_status.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/http_status.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/json_format.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/json_format.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/pin_tu.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/pin_tu.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/qr_code.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/qr_code.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/rvc.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/rvc.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/sovits_tts.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/sovits_tts.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/stt.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/stt.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/token_gen.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/token_gen.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/url_parser.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/url_parser.cpython-312.pyc
Normal file
Binary file not shown.
440
flask-dev-api/blueprints/ai_dubbing.py
Normal file
440
flask-dev-api/blueprints/ai_dubbing.py
Normal file
@@ -0,0 +1,440 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ai-dubbing AI 配音蓝图
|
||||
通过 subprocess 调用 GPT-SoVITS 自带的 runtime\python.exe 实现文本转语音
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import uuid
|
||||
import glob
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import tempfile
|
||||
from flask import Blueprint, render_template, request, jsonify, send_file, after_this_request
|
||||
|
||||
bp = Blueprint('ai_dubbing', __name__, url_prefix='/ai-dubbing')
|
||||
|
||||
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_dubbing_config.json')
|
||||
WORKER_SCRIPT = os.path.join(BASE_DIR, 'utils', 'sovits_worker.py')
|
||||
|
||||
# 子进程管理
|
||||
_proc = None
|
||||
_proc_lock = threading.Lock()
|
||||
_proc_stderr = []
|
||||
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'version': None}
|
||||
|
||||
# 合成请求锁
|
||||
_synth_lock = threading.Lock()
|
||||
|
||||
# 任务结果缓存
|
||||
_task_results_local = {}
|
||||
|
||||
TEXT_LANGUAGES = {
|
||||
'zh': '中文', 'en': '英文', 'ja': '日文', 'ko': '韩文',
|
||||
'yue': '粤语', 'all_zh': '全部中文', 'all_ja': '全部日文',
|
||||
'all_yue': '全部粤语', 'all_ko': '全部韩文',
|
||||
'auto': '多语种混合', 'auto_yue': '多语种混合(粤语)',
|
||||
}
|
||||
CUT_METHODS = ['cut0', 'cut1', 'cut2', 'cut3', 'cut4', 'cut5']
|
||||
CUT_METHOD_NAMES = {
|
||||
'cut0': '不切分', 'cut1': '凑四句一切', 'cut2': '凑50字一切',
|
||||
'cut3': '按中文句号。切', 'cut4': '按英文句号.切', 'cut5': '按标点符号切',
|
||||
}
|
||||
|
||||
|
||||
def _default_config():
|
||||
return {
|
||||
'sovits_project_path': r'E:\AI\GPT-SoVITS-v4',
|
||||
'sovits_config_yaml': r'GPT_SoVITS\configs\tts_infer.yaml',
|
||||
'last_prompt_text': '', 'last_prompt_lang': 'zh',
|
||||
'last_text_lang': 'zh', 'last_text_split_method': 'cut5',
|
||||
'top_k': 5, 'top_p': 1.0, 'temperature': 1.0,
|
||||
'batch_size': 1, 'speed_factor': 1.0, 'seed': -1,
|
||||
'gpt_model_dir': '', 'sovits_model_dir': '',
|
||||
'last_gpt_model': '', 'last_sovits_model': '',
|
||||
'refer_audio_folder': '',
|
||||
}
|
||||
|
||||
|
||||
def _load_config():
|
||||
cfg = _default_config()
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
saved = json.load(f)
|
||||
cfg.update(saved)
|
||||
return cfg
|
||||
|
||||
|
||||
def _save_config(cfg):
|
||||
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _scan_models(directory, pattern):
|
||||
"""扫描目录下指定格式的模型文件,返回文件名列表"""
|
||||
if not directory or not os.path.isdir(directory):
|
||||
return []
|
||||
files = glob.glob(os.path.join(directory, pattern))
|
||||
files.sort(key=lambda x: os.path.basename(x).lower())
|
||||
return [os.path.basename(f) for f in files]
|
||||
|
||||
|
||||
def _send_msg(proc, obj):
|
||||
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||
proc.stdin.write(struct.pack('<I', len(data)))
|
||||
proc.stdin.write(data)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def _recv_msg(proc):
|
||||
raw_len = proc.stdout.read(4)
|
||||
if not raw_len:
|
||||
return None
|
||||
msg_len = struct.unpack('<I', raw_len)[0]
|
||||
data = proc.stdout.read(msg_len)
|
||||
return json.loads(data.decode('utf-8'))
|
||||
|
||||
|
||||
def _init_engine():
|
||||
global _proc, _engine_status
|
||||
if _engine_status['initialized'] and _proc and _proc.poll() is None:
|
||||
return True, None
|
||||
|
||||
with _proc_lock:
|
||||
if _engine_status['initialized'] and _proc and _proc.poll() is None:
|
||||
return True, None
|
||||
if _engine_status['loading']:
|
||||
return False, '引擎正在加载中,请稍候...'
|
||||
|
||||
_engine_status['loading'] = True
|
||||
_engine_status['error'] = None
|
||||
|
||||
cfg = _load_config()
|
||||
sovits_path = cfg.get('sovits_project_path', '').strip()
|
||||
sovits_config_yaml = cfg.get('sovits_config_yaml', r'GPT_SoVITS\configs\tts_infer.yaml')
|
||||
|
||||
if not sovits_path or not os.path.isdir(sovits_path):
|
||||
_engine_status['loading'] = False
|
||||
_engine_status['error'] = f'GPT-SoVITS 项目路径不存在: {sovits_path}'
|
||||
return False, _engine_status['error']
|
||||
|
||||
runtime_python = os.path.join(sovits_path, 'runtime', 'python.exe')
|
||||
if not os.path.exists(runtime_python):
|
||||
_engine_status['loading'] = False
|
||||
_engine_status['error'] = f'找不到 GPT-SoVITS 运行时: {runtime_python}'
|
||||
return False, _engine_status['error']
|
||||
|
||||
try:
|
||||
creationflags = 0x08000000 if os.name == 'nt' else 0
|
||||
_proc = subprocess.Popen(
|
||||
[runtime_python, WORKER_SCRIPT, sovits_path, sovits_config_yaml],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
_proc_stderr.clear()
|
||||
def _read_stderr():
|
||||
try:
|
||||
for line in _proc.stderr:
|
||||
_proc_stderr.append(line.decode('utf-8', errors='replace').rstrip())
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=_read_stderr, daemon=True).start()
|
||||
|
||||
msg = _recv_msg(_proc)
|
||||
if msg and msg.get('type') == 'ready':
|
||||
_engine_status['initialized'] = True
|
||||
_engine_status['version'] = msg.get('version', 'unknown')
|
||||
_engine_status['error'] = None
|
||||
return True, None
|
||||
else:
|
||||
err = msg.get('error', '未知错误') if msg else '子进程无响应'
|
||||
if _proc_stderr:
|
||||
err += '\n--- 日志 ---\n' + '\n'.join(_proc_stderr[-10:])
|
||||
_engine_status['error'] = err
|
||||
_kill_proc()
|
||||
return False, err
|
||||
|
||||
except Exception as e:
|
||||
_engine_status['error'] = str(e)
|
||||
_kill_proc()
|
||||
return False, str(e)
|
||||
finally:
|
||||
_engine_status['loading'] = False
|
||||
|
||||
|
||||
def _kill_proc():
|
||||
global _proc
|
||||
if _proc:
|
||||
try:
|
||||
_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_proc = None
|
||||
|
||||
|
||||
def _reload_model(model_type, path):
|
||||
"""向子进程发送模型重载请求,model_type: 'reload_gpt' 或 'reload_sovits'"""
|
||||
with _synth_lock:
|
||||
if not _proc or _proc.poll() is not None:
|
||||
return False, '子进程未运行'
|
||||
try:
|
||||
_send_msg(_proc, {'type': model_type, 'path': path})
|
||||
msg = _recv_msg(_proc)
|
||||
if msg and msg.get('type') == 'done':
|
||||
return True, None
|
||||
else:
|
||||
return False, msg.get('error', '未知错误') if msg else '子进程无响应'
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _synthesize(inputs, output_path):
|
||||
with _synth_lock:
|
||||
if not _proc or _proc.poll() is not None:
|
||||
return False, '子进程未运行'
|
||||
try:
|
||||
_send_msg(_proc, {
|
||||
'type': 'synthesize',
|
||||
'inputs': inputs,
|
||||
'output_path': output_path,
|
||||
})
|
||||
msg = _recv_msg(_proc)
|
||||
if msg and msg.get('type') == 'done':
|
||||
return True, msg.get('output_path')
|
||||
else:
|
||||
return False, msg.get('error', '未知错误') if msg else '子进程无响应'
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
# ==================== 路由 ====================
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
cfg = _load_config()
|
||||
return render_template('ai_dubbing.html',
|
||||
config=cfg, languages=TEXT_LANGUAGES,
|
||||
cut_methods=CUT_METHODS, cut_method_names=CUT_METHOD_NAMES)
|
||||
|
||||
|
||||
@bp.route('/config', methods=['GET'])
|
||||
def get_config():
|
||||
cfg = _load_config()
|
||||
cfg['engine_status'] = _engine_status
|
||||
# 扫描模型列表
|
||||
gpt_dir = cfg.get('gpt_model_dir', '')
|
||||
sovits_dir = cfg.get('sovits_model_dir', '')
|
||||
cfg['gpt_models'] = _scan_models(gpt_dir, '*.ckpt')
|
||||
cfg['sovits_models'] = _scan_models(sovits_dir, '*.pth')
|
||||
# 扫描参考音频
|
||||
refer_folder = cfg.get('refer_audio_folder', '')
|
||||
if refer_folder and os.path.isdir(refer_folder):
|
||||
AUDIO_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.aac', '.m4a', '.wma')
|
||||
files = [os.path.join(refer_folder, f) for f in os.listdir(refer_folder)
|
||||
if os.path.isfile(os.path.join(refer_folder, f)) and f.lower().endswith(AUDIO_EXTS)]
|
||||
files.sort(key=lambda x: os.path.basename(x).lower())
|
||||
cfg['audio_files'] = files
|
||||
else:
|
||||
cfg['audio_files'] = []
|
||||
return jsonify(cfg)
|
||||
|
||||
|
||||
@bp.route('/config', methods=['POST'])
|
||||
def save_config():
|
||||
data = request.get_json()
|
||||
cfg = _load_config()
|
||||
for key in ('sovits_project_path', 'sovits_config_yaml',
|
||||
'last_prompt_text', 'last_prompt_lang',
|
||||
'last_text_lang', 'last_text_split_method',
|
||||
'top_k', 'top_p', 'temperature', 'batch_size', 'speed_factor', 'seed',
|
||||
'gpt_model_dir', 'sovits_model_dir',
|
||||
'last_gpt_model', 'last_sovits_model', 'refer_audio_folder'):
|
||||
if key in data:
|
||||
cfg[key] = data[key]
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/scan-audio-folder', methods=['POST'])
|
||||
def scan_audio_folder():
|
||||
data = request.get_json()
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder or not os.path.isdir(folder):
|
||||
return jsonify({'success': False, 'error': '文件夹不存在'})
|
||||
AUDIO_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.aac', '.m4a', '.wma')
|
||||
files = [os.path.join(folder, f) for f in os.listdir(folder)
|
||||
if os.path.isfile(os.path.join(folder, f)) and f.lower().endswith(AUDIO_EXTS)]
|
||||
files.sort(key=lambda x: os.path.basename(x).lower())
|
||||
return jsonify({'success': True, 'files': files, 'count': len(files)})
|
||||
|
||||
|
||||
@bp.route('/engine-status', methods=['GET'])
|
||||
def engine_status():
|
||||
status = dict(_engine_status)
|
||||
status['logs'] = _proc_stderr[-50:]
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@bp.route('/clear-logs', methods=['POST'])
|
||||
def clear_logs():
|
||||
_proc_stderr.clear()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/init-engine', methods=['POST'])
|
||||
def init_engine():
|
||||
ok, err = _init_engine()
|
||||
if ok:
|
||||
return jsonify({'success': True, 'version': _engine_status.get('version')})
|
||||
return jsonify({'success': False, 'error': err})
|
||||
|
||||
|
||||
@bp.route('/reload-engine', methods=['POST'])
|
||||
def reload_engine():
|
||||
global _engine_status
|
||||
_kill_proc()
|
||||
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'version': None}
|
||||
ok, err = _init_engine()
|
||||
if ok:
|
||||
return jsonify({'success': True, 'version': _engine_status.get('version')})
|
||||
return jsonify({'success': False, 'error': err})
|
||||
|
||||
|
||||
@bp.route('/stop-engine', methods=['POST'])
|
||||
def stop_engine():
|
||||
global _engine_status
|
||||
_kill_proc()
|
||||
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'version': None}
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/switch-model', methods=['POST'])
|
||||
def switch_model():
|
||||
"""切换 GPT 或 SoVITS 模型"""
|
||||
if not _engine_status['initialized']:
|
||||
return jsonify({'success': False, 'error': '引擎未初始化'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
model_type = data.get('type') # 'gpt' 或 'sovits'
|
||||
filename = data.get('filename')
|
||||
if not model_type or not filename:
|
||||
return jsonify({'success': False, 'error': '缺少参数'}), 400
|
||||
|
||||
cfg = _load_config()
|
||||
if model_type == 'gpt':
|
||||
model_dir = cfg.get('gpt_model_dir', '')
|
||||
full_path = os.path.join(model_dir, filename) if model_dir else filename
|
||||
ok, err = _reload_model('reload_gpt', full_path)
|
||||
if ok:
|
||||
cfg['last_gpt_model'] = filename
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True, 'message': f'GPT 模型已切换: {filename}'})
|
||||
return jsonify({'success': False, 'error': err})
|
||||
elif model_type == 'sovits':
|
||||
model_dir = cfg.get('sovits_model_dir', '')
|
||||
full_path = os.path.join(model_dir, filename) if model_dir else filename
|
||||
ok, err = _reload_model('reload_sovits', full_path)
|
||||
if ok:
|
||||
cfg['last_sovits_model'] = filename
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True, 'message': f'SoVITS 模型已切换: {filename}'})
|
||||
return jsonify({'success': False, 'error': err})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': '未知模型类型'}), 400
|
||||
|
||||
|
||||
@bp.route('/synthesize', methods=['POST'])
|
||||
def synthesize():
|
||||
if not _engine_status['initialized']:
|
||||
return jsonify({'success': False, 'error': '引擎未初始化,请先初始化'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
text = (data.get('text') or '').strip()
|
||||
if not text:
|
||||
return jsonify({'success': False, 'error': '请输入要合成的文本'}), 400
|
||||
ref_audio_path = (data.get('ref_audio_path') or '').strip()
|
||||
if not ref_audio_path:
|
||||
return jsonify({'success': False, 'error': '请选择参考音频'}), 400
|
||||
|
||||
cfg = _load_config()
|
||||
for key in ('last_prompt_text', 'last_prompt_lang',
|
||||
'last_text_lang', 'last_text_split_method',
|
||||
'top_k', 'top_p', 'temperature', 'batch_size', 'speed_factor', 'seed'):
|
||||
if key in data:
|
||||
cfg[key] = data[key]
|
||||
_save_config(cfg)
|
||||
|
||||
task_id = uuid.uuid4().hex
|
||||
output_path = os.path.join(tempfile.gettempdir(), f'ai_dub_{task_id}.wav')
|
||||
|
||||
inputs = {
|
||||
'text': text,
|
||||
'text_lang': data.get('text_lang', 'zh'),
|
||||
'ref_audio_path': ref_audio_path,
|
||||
'prompt_text': data.get('prompt_text', ''),
|
||||
'prompt_lang': data.get('prompt_lang', 'zh'),
|
||||
'top_k': data.get('top_k', 5),
|
||||
'top_p': data.get('top_p', 1.0),
|
||||
'temperature': data.get('temperature', 1.0),
|
||||
'text_split_method': data.get('text_split_method', 'cut5'),
|
||||
'batch_size': data.get('batch_size', 1),
|
||||
'speed_factor': data.get('speed_factor', 1.0),
|
||||
'seed': data.get('seed', -1),
|
||||
'parallel_infer': True,
|
||||
'repetition_penalty': 1.35,
|
||||
}
|
||||
|
||||
def _do_synth():
|
||||
ok, result = _synthesize(inputs, output_path)
|
||||
if ok:
|
||||
_task_results_local[task_id] = {'status': 'done', 'file': output_path}
|
||||
else:
|
||||
_task_results_local[task_id] = {'status': 'error', 'error': result}
|
||||
|
||||
_task_results_local[task_id] = {'status': 'synthesizing'}
|
||||
t = threading.Thread(target=_do_synth, daemon=True)
|
||||
t.start()
|
||||
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
|
||||
|
||||
@bp.route('/status/<task_id>')
|
||||
def task_status(task_id):
|
||||
info = _task_results_local.get(task_id)
|
||||
if not info:
|
||||
return jsonify({'success': False, 'error': '任务不存在'}), 404
|
||||
|
||||
resp = {'success': True, 'progress': info}
|
||||
if info['status'] == 'done':
|
||||
resp['audio_url'] = f'/ai-dubbing/audio/{task_id}'
|
||||
elif info['status'] == 'error':
|
||||
resp['progress'] = {'status': 'error', 'error': info.get('error', '未知错误')}
|
||||
_task_results_local.pop(task_id, None)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@bp.route('/audio/<task_id>')
|
||||
def get_audio(task_id):
|
||||
info = _task_results_local.pop(task_id, None)
|
||||
audio_path = info['file'] if info else os.path.join(tempfile.gettempdir(), f'ai_dub_{task_id}.wav')
|
||||
if not os.path.exists(audio_path):
|
||||
return jsonify({'error': '音频文件不存在'}), 404
|
||||
|
||||
@after_this_request
|
||||
def cleanup(resp):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except OSError:
|
||||
pass
|
||||
return resp
|
||||
|
||||
return send_file(audio_path, mimetype='audio/wav')
|
||||
60
flask-dev-api/blueprints/base64_codec.py
Normal file
60
flask-dev-api/blueprints/base64_codec.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
base64-de-in-code 编解码蓝图
|
||||
"""
|
||||
import base64
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
bp = Blueprint('base64_codec', __name__, url_prefix='/base64-de-in-code')
|
||||
|
||||
|
||||
def _fallback_decode(data_bytes):
|
||||
"""降级解码:当 encoding_utils 不可用时使用"""
|
||||
for enc in ['utf-8', 'gbk', 'gb2312', 'cp936']:
|
||||
try:
|
||||
return data_bytes.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return "解码失败,无法识别编码。"
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('base64.html')
|
||||
|
||||
|
||||
@bp.route('/decode', methods=['POST'])
|
||||
def decode():
|
||||
data = request.get_json()
|
||||
base64_input = data.get('input_text', '').strip()
|
||||
if not base64_input:
|
||||
return jsonify({'success': False, 'error': '输入不能为空。'})
|
||||
try:
|
||||
decoded_bytes = base64.b64decode(base64_input)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'Base64 解码失败: {str(e)}'})
|
||||
|
||||
try:
|
||||
from utils.encoding_utils import detect_and_decode
|
||||
decoded_result = detect_and_decode(decoded_bytes)
|
||||
except Exception:
|
||||
decoded_result = _fallback_decode(decoded_bytes)
|
||||
|
||||
return jsonify({'success': True, 'result': decoded_result})
|
||||
|
||||
|
||||
@bp.route('/encode', methods=['POST'])
|
||||
def encode():
|
||||
data = request.get_json()
|
||||
text_to_encode = data.get('text_to_encode', '')
|
||||
encoding = data.get('encoding', 'utf-8')
|
||||
if text_to_encode is None:
|
||||
return jsonify({'success': False, 'error': '输入不能为空。'})
|
||||
try:
|
||||
encoded_bytes = text_to_encode.encode(encoding)
|
||||
except LookupError:
|
||||
return jsonify({'success': False, 'error': f'未知的编码格式: {encoding}'})
|
||||
except UnicodeEncodeError as e:
|
||||
return jsonify({'success': False, 'error': f'编码失败: {str(e)}'})
|
||||
encoded_string = base64.b64encode(encoded_bytes).decode('ascii')
|
||||
return jsonify({'success': True, 'result': encoded_string})
|
||||
9
flask-dev-api/blueprints/chmod_calc.py
Normal file
9
flask-dev-api/blueprints/chmod_calc.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint('chmod_calc', __name__, url_prefix='/chmod-calc')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('chmod_calc.html')
|
||||
196
flask-dev-api/blueprints/content_tag.py
Normal file
196
flask-dev-api/blueprints/content_tag.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
content-tag 文章标签生成蓝图
|
||||
支持 Ollama (/api/generate) 和 llama.cpp/OpenAI 兼容 (/v1/chat/completions)
|
||||
"""
|
||||
import requests
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from utils.stats_db import get_content_tag_settings, update_content_tag_settings
|
||||
|
||||
bp = Blueprint('content_tag', __name__, url_prefix='/content-tag')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('content_tag.html')
|
||||
|
||||
|
||||
@bp.route('/settings', methods=["GET"])
|
||||
def get_settings():
|
||||
return jsonify({"success": True, "settings": get_content_tag_settings()})
|
||||
|
||||
|
||||
@bp.route('/settings', methods=["POST"])
|
||||
def save_settings():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "缺少数据"}), 400
|
||||
update_content_tag_settings(data)
|
||||
return jsonify({"success": True, "settings": get_content_tag_settings()})
|
||||
|
||||
|
||||
@bp.route('/models', methods=["POST"])
|
||||
def list_models():
|
||||
"""获取 Ollama 可用模型列表"""
|
||||
data = request.get_json()
|
||||
api_url = (data.get("api_url") or "").strip()
|
||||
|
||||
if not api_url:
|
||||
return jsonify({"success": False, "error": "请填写 API 地址"})
|
||||
|
||||
# /api/generate -> base url -> /api/tags
|
||||
base_url = api_url.rsplit('/api/', 1)[0]
|
||||
tags_url = base_url + '/api/tags'
|
||||
|
||||
try:
|
||||
r = requests.get(tags_url, timeout=10)
|
||||
if r.status_code == 200:
|
||||
models = [m["name"] for m in r.json().get("models", [])]
|
||||
return jsonify({"success": True, "models": models})
|
||||
else:
|
||||
return jsonify({"success": False, "error": f"获取模型失败: {r.status_code}"})
|
||||
except requests.exceptions.ConnectionError:
|
||||
return jsonify({"success": False, "error": f"无法连接到: {tags_url}"})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
@bp.route('/test-api', methods=["POST"])
|
||||
def test_api():
|
||||
data = request.get_json()
|
||||
api_url = (data.get("api_url") or "").strip()
|
||||
model = (data.get("model") or "").strip()
|
||||
software = data.get("software", "llamacpp")
|
||||
|
||||
if not api_url:
|
||||
return jsonify({"success": False, "error": "请填写 API 地址"})
|
||||
|
||||
try:
|
||||
if software == "ollama":
|
||||
base_url = api_url.rsplit('/api/', 1)[0]
|
||||
r = requests.get(base_url, timeout=10)
|
||||
if r.status_code != 200 or 'Ollama is running' not in r.text:
|
||||
return jsonify({"success": False, "error": f"Ollama 未运行: {base_url}"})
|
||||
tags_url = base_url + '/api/tags'
|
||||
r2 = requests.get(tags_url, timeout=10)
|
||||
if r2.status_code != 200:
|
||||
return jsonify({"success": False, "error": "Ollama 获取模型列表失败"})
|
||||
models = [m["name"] for m in r2.json().get("models", [])]
|
||||
return jsonify({"success": True, "message": f"连接成功,共 {len(models)} 个模型", "models": models})
|
||||
else:
|
||||
payload = {"model": model or "test", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5}
|
||||
r = requests.post(api_url, json=payload, timeout=10)
|
||||
if r.status_code == 200:
|
||||
result = r.json()
|
||||
name = result.get("model", model)
|
||||
return jsonify({"success": True, "message": f"连接成功,模型: {name}"})
|
||||
else:
|
||||
return jsonify({"success": False, "error": f"API 返回 {r.status_code}: {r.text[:200]}"})
|
||||
except requests.exceptions.ConnectionError:
|
||||
return jsonify({"success": False, "error": f"无法连接到: {api_url}"})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
@bp.route('/generate', methods=["POST"])
|
||||
def generate_tags():
|
||||
data = request.get_json()
|
||||
api_url = (data.get("api_url") or "").strip()
|
||||
model = (data.get("model") or "").strip()
|
||||
software = data.get("software", "llamacpp")
|
||||
content = (data.get("content") or "").strip()
|
||||
max_tags = data.get("max_tags", 10)
|
||||
min_length = data.get("min_length", 2)
|
||||
max_length = data.get("max_length", 6)
|
||||
|
||||
if not api_url:
|
||||
return jsonify({"success": False, "error": "请填写 API 地址"})
|
||||
if software == "ollama" and not model:
|
||||
return jsonify({"success": False, "error": "Ollama 需要填写模型名称"})
|
||||
if not content:
|
||||
return jsonify({"success": False, "error": "请输入文章内容"})
|
||||
|
||||
custom_system = (data.get("system_msg") or "").strip()
|
||||
custom_user = (data.get("user_msg") or "").strip()
|
||||
|
||||
system_msg = custom_system if custom_system else "你是一个专门生成文章标签的助手,请你根据我给你的文章的内容总结并生成一系列的标签,格式可以参考[关键词1, 关键词2, 关键词3].你只需要给我生成这种形式的标签即可,其他分析内容无需输出."
|
||||
user_template = custom_user if custom_user else "请严格按照以下要求,从提供的文章内容中提取关键词。\n\n文章内容:\n{content}\n\n要求:\n- 提取最多 {max_tags} 个最能概括文章主旨和核心概念的关键词。\n- 关键词必须来源于文章内容,准确反映文章主题。\n- 每个关键词的长度必须在 {min_length} 到 {max_length} 个字符之间。\n- 输出格式为:关键词1, 关键词2, 关键词3, ...\n- 只输出关键词列表,不要有任何其他解释或前缀。"
|
||||
|
||||
user_msg = user_template.format(content=content, max_tags=max_tags, min_length=min_length, max_length=max_length)
|
||||
|
||||
if software == "ollama":
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": user_msg,
|
||||
"system": system_msg,
|
||||
"stream": False,
|
||||
"think": False,
|
||||
"options": {"top_p": 0.9, "temperature": 0.1, "num_predict": 2048}
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"messages": [
|
||||
{"role": "system", "content": system_msg},
|
||||
{"role": "user", "content": user_msg}
|
||||
],
|
||||
"stream": False,
|
||||
"temperature": 0.1,
|
||||
"top_p": 0.9,
|
||||
"max_tokens": 2048,
|
||||
}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
response = requests.post(api_url, json=payload, timeout=120)
|
||||
|
||||
if response.status_code != 200:
|
||||
return jsonify({"success": False, "error": f"API 返回错误: {response.status_code} - {response.text[:200]}"})
|
||||
|
||||
result = response.json()
|
||||
|
||||
if software == "ollama":
|
||||
llm_output = result.get("response", "").strip()
|
||||
actual_model = result.get("model", model)
|
||||
else:
|
||||
choices = result.get("choices", [])
|
||||
if not choices:
|
||||
return jsonify({"success": False, "error": f"API 响应无 choices: {json.dumps(result, ensure_ascii=False)[:200]}"})
|
||||
llm_output = choices[0].get("message", {}).get("content", "").strip()
|
||||
actual_model = result.get("model", model)
|
||||
|
||||
think_match = re.search(r'<think>(.*?)</think>', llm_output, re.DOTALL)
|
||||
ai_think = think_match.group(1).strip() if think_match else ""
|
||||
|
||||
clean_output = re.sub(r'<think>.*?</think>', '', llm_output, count=1, flags=re.DOTALL).strip()
|
||||
raw_tags = [tag.strip() for tag in re.split(r'[,,;;\n]+', clean_output) if tag.strip()]
|
||||
|
||||
seen = set()
|
||||
tags = []
|
||||
for tag in raw_tags:
|
||||
if tag not in seen:
|
||||
seen.add(tag)
|
||||
tags.append(tag)
|
||||
|
||||
elapsed = round(time.time() - start_time, 2)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"tags": tags,
|
||||
"think": ai_think,
|
||||
"model": actual_model,
|
||||
"consume": elapsed
|
||||
})
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return jsonify({"success": False, "error": f"无法连接到 API 地址: {api_url}"})
|
||||
except requests.exceptions.Timeout:
|
||||
return jsonify({"success": False, "error": "API 请求超时(120秒)"})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": f"请求失败: {str(e)}"})
|
||||
184
flask-dev-api/blueprints/down_video.py
Normal file
184
flask-dev-api/blueprints/down_video.py
Normal file
@@ -0,0 +1,184 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
down-video 视频下载蓝图
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import queue
|
||||
from flask import Blueprint, render_template, request, jsonify, send_file, Response
|
||||
|
||||
from config import DEFAULT_OUTPUT_DIR, PROXY_URL
|
||||
from utils.down_video_utils import (
|
||||
check_ffmpeg,
|
||||
check_deno,
|
||||
is_twitter_url,
|
||||
is_bilibili_url,
|
||||
is_instagram_url,
|
||||
is_youtube_url,
|
||||
download_video,
|
||||
)
|
||||
from utils.stats_db import add_recent_path, get_recent_paths, delete_recent_path, clear_recent_paths
|
||||
|
||||
bp = Blueprint('dv_cookies', __name__, url_prefix='/down-video')
|
||||
|
||||
# 用于存储下载进度的队列
|
||||
progress_queues = {}
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
ffmpeg_ok = check_ffmpeg()
|
||||
deno_ok = check_deno()
|
||||
return render_template('down_video.html', ffmpeg_ok=ffmpeg_ok, deno_ok=deno_ok)
|
||||
|
||||
|
||||
@bp.route('/file')
|
||||
def serve_file():
|
||||
filepath = request.args.get("path", "")
|
||||
as_download = request.args.get("download", "0") == "1"
|
||||
if not filepath:
|
||||
return "Missing path", 400
|
||||
|
||||
filepath = os.path.abspath(filepath)
|
||||
if not os.path.exists(filepath) or not os.path.isfile(filepath):
|
||||
return "File not found", 404
|
||||
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
if ext not in ('.mp4', '.m4a', '.webm', '.mkv', '.mov', '.avi'):
|
||||
return "File type not allowed", 403
|
||||
|
||||
mime_types = {
|
||||
'.mp4': 'video/mp4',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo',
|
||||
}
|
||||
mimetype = mime_types.get(ext, 'application/octet-stream')
|
||||
|
||||
response = send_file(
|
||||
filepath,
|
||||
mimetype=mimetype,
|
||||
conditional=True,
|
||||
)
|
||||
response.headers['Accept-Ranges'] = 'bytes'
|
||||
if not as_download:
|
||||
response.headers['Content-Disposition'] = 'inline'
|
||||
return response
|
||||
|
||||
|
||||
@bp.route('/download', methods=["POST"])
|
||||
def download():
|
||||
data = request.get_json()
|
||||
url = data.get("url", "").strip() if data else ""
|
||||
output_dir = data.get("output_dir", DEFAULT_OUTPUT_DIR).strip() if data else DEFAULT_OUTPUT_DIR
|
||||
download_id = data.get("download_id", "") if data else ""
|
||||
use_proxy = data.get("use_proxy", False) if data else False
|
||||
proxy_url_input = (data.get("proxy_url") or "").strip() if data else ""
|
||||
|
||||
if not url:
|
||||
return jsonify({"success": False, "message": "请输入视频链接"}), 400
|
||||
if is_twitter_url(url):
|
||||
platform = 'twitter'
|
||||
elif is_bilibili_url(url):
|
||||
platform = 'bilibili'
|
||||
elif is_instagram_url(url):
|
||||
platform = 'instagram'
|
||||
elif is_youtube_url(url):
|
||||
platform = 'youtube'
|
||||
else:
|
||||
return jsonify({"success": False, "message": "仅支持 Twitter/X、Bilibili 和 Instagram 视频链接"}), 400
|
||||
if not output_dir:
|
||||
output_dir = DEFAULT_OUTPUT_DIR
|
||||
|
||||
# 确定使用的代理地址
|
||||
proxy_url = proxy_url_input if use_proxy and proxy_url_input else (PROXY_URL if use_proxy else None)
|
||||
|
||||
# 创建进度队列
|
||||
progress_queue = queue.Queue()
|
||||
if download_id:
|
||||
progress_queues[download_id] = progress_queue
|
||||
|
||||
def progress_callback(data):
|
||||
progress_queue.put(data)
|
||||
|
||||
try:
|
||||
# 发送开始合并的消息
|
||||
def send_merge_status():
|
||||
progress_queue.put({
|
||||
'type': 'merging',
|
||||
'message': '正在合并视频和音频...'
|
||||
})
|
||||
|
||||
success, message, title, filepath = download_video(url, output_dir, platform, progress_callback, proxy_url=proxy_url)
|
||||
|
||||
# 如果是bilibili或youtube,可能有合并过程,发送合并状态
|
||||
if platform in ('bilibili', 'youtube') and filepath and '_merged' in filepath:
|
||||
send_merge_status()
|
||||
|
||||
return jsonify({"success": success, "message": message, "title": title, "filepath": filepath})
|
||||
finally:
|
||||
# 清理队列
|
||||
if download_id and download_id in progress_queues:
|
||||
del progress_queues[download_id]
|
||||
|
||||
|
||||
@bp.route('/progress/<download_id>')
|
||||
def progress_stream(download_id):
|
||||
"""SSE端点 - 实时推送下载进度"""
|
||||
|
||||
def generate():
|
||||
progress_queue = progress_queues.get(download_id)
|
||||
if not progress_queue:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': '下载任务不存在'})}\n\n"
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
data = progress_queue.get(timeout=30)
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
if data.get('type') == 'progress' and data.get('percent', 0) >= 100:
|
||||
break
|
||||
except queue.Empty:
|
||||
# 发送心跳保持连接
|
||||
yield f":\n\n"
|
||||
continue
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ===== 保存路径历史 =====
|
||||
|
||||
@bp.route('/save-paths', methods=['GET'])
|
||||
def get_save_paths():
|
||||
paths = get_recent_paths(limit=10)
|
||||
return jsonify({'paths': paths})
|
||||
|
||||
|
||||
@bp.route('/save-paths', methods=['POST'])
|
||||
def add_save_path():
|
||||
data = request.get_json()
|
||||
path = data.get('path', '').strip() if data else ''
|
||||
if not path:
|
||||
return jsonify({'success': False, 'message': '路径不能为空'}), 400
|
||||
add_recent_path(path)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/save-paths/delete', methods=['POST'])
|
||||
def delete_save_path():
|
||||
data = request.get_json()
|
||||
path = data.get('path', '').strip() if data else ''
|
||||
if not path:
|
||||
return jsonify({'success': False, 'message': '路径不能为空'}), 400
|
||||
delete_recent_path(path)
|
||||
return jsonify({'success': True})
|
||||
49
flask-dev-api/blueprints/fen_ci.py
Normal file
49
flask-dev-api/blueprints/fen_ci.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
fen-ci 分词蓝图
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
from utils.fen_ci_utils import tokenizer, token_auth
|
||||
|
||||
bp = Blueprint('fen_ci', __name__, url_prefix='/fen-ci')
|
||||
|
||||
|
||||
def _authenticate():
|
||||
"""检查请求头中的 Authorization token"""
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return None, "缺少或格式错误的Authorization头。请使用 'Authorization: Bearer <your_token>'"
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
user = token_auth.get_user(token)
|
||||
if not user:
|
||||
return None, "无效的Token"
|
||||
return user, None
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('fen_ci.html')
|
||||
|
||||
|
||||
@bp.route('/tokenize', methods=['POST'])
|
||||
def tokenize():
|
||||
user, error_msg = _authenticate()
|
||||
if not user:
|
||||
return jsonify({'success': False, 'error': error_msg}), 401
|
||||
|
||||
data = request.get_json()
|
||||
input_text = data.get('input_text', '')
|
||||
|
||||
try:
|
||||
tokens = tokenizer.tokenize(input_text)
|
||||
stats = tokenizer.get_stats(tokens)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'tokens': tokens,
|
||||
'stats': stats,
|
||||
'original_text': input_text,
|
||||
'requested_by': user
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'分词处理失败: {str(e)}'}), 500
|
||||
9
flask-dev-api/blueprints/http_status.py
Normal file
9
flask-dev-api/blueprints/http_status.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint('http_status', __name__, url_prefix='/http-status')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('http_status.html')
|
||||
9
flask-dev-api/blueprints/json_format.py
Normal file
9
flask-dev-api/blueprints/json_format.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint('json_format', __name__, url_prefix='/json-format')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('json_format.html')
|
||||
447
flask-dev-api/blueprints/pin_tu.py
Normal file
447
flask-dev-api/blueprints/pin_tu.py
Normal file
@@ -0,0 +1,447 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pin-tu 图片浏览蓝图
|
||||
用户自行指定浏览路径(root 用 URL-safe base64 编码放在 URL 中)
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import base64
|
||||
from flask import Blueprint, render_template, send_from_directory, request, abort, jsonify
|
||||
|
||||
from utils.pin_tu_utils import (
|
||||
find_media_in_folder,
|
||||
get_subfolders,
|
||||
build_breadcrumbs,
|
||||
is_safe_path,
|
||||
)
|
||||
from utils.stats_db import (
|
||||
add_search, get_search_history, delete_search_history, clear_search_history,
|
||||
add_recent_path, get_recent_paths, delete_recent_path, clear_recent_paths,
|
||||
)
|
||||
|
||||
bp = Blueprint('pin_tu', __name__, url_prefix='/pin-tu')
|
||||
|
||||
|
||||
def _decode_root(b64_str):
|
||||
"""URL-safe base64 解码为原始路径"""
|
||||
try:
|
||||
# URL-safe → standard base64
|
||||
padded = b64_str.replace('-', '+').replace('_', '/')
|
||||
# 补齐 =
|
||||
pad = len(padded) % 4
|
||||
if pad:
|
||||
padded += '=' * (4 - pad)
|
||||
return base64.b64decode(padded).decode('utf-8')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _encode_root(path_str):
|
||||
"""路径编码为 URL-safe base64 字符串"""
|
||||
b = base64.b64encode(path_str.encode('utf-8')).decode('ascii')
|
||||
return b.replace('+', '-').replace('/', '_').rstrip('=')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
return render_template('pin_tu.html', mode='index', root='', root_b64='')
|
||||
|
||||
|
||||
@bp.route('/browse/<string:root_b64>')
|
||||
def browse(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
|
||||
# 记录最近访问路径
|
||||
add_recent_path(root_path)
|
||||
|
||||
subpath = request.args.get('p', '')
|
||||
mode = request.args.get('mode', 'list')
|
||||
if mode not in ['list', 'grid', 'manga']:
|
||||
mode = 'list'
|
||||
|
||||
root_abs = os.path.normpath(root_path)
|
||||
if subpath:
|
||||
current_path = os.path.normpath(os.path.join(root_abs, subpath))
|
||||
else:
|
||||
current_path = root_abs
|
||||
|
||||
if not os.path.isdir(root_abs):
|
||||
abort(404)
|
||||
if not is_safe_path(root_abs, current_path):
|
||||
abort(403)
|
||||
|
||||
image_files, video_files, other_files = find_media_in_folder(current_path)
|
||||
subfolders = get_subfolders(current_path)
|
||||
breadcrumbs = build_breadcrumbs(subpath, base_url=f'/pin-tu/browse/{root_b64}')
|
||||
current_path_name = os.path.basename(current_path) if current_path != root_abs else os.path.basename(root_abs)
|
||||
|
||||
if mode == 'manga':
|
||||
return render_template(
|
||||
'pin_tu_manga.html',
|
||||
images=image_files,
|
||||
current_path_name=current_path_name,
|
||||
current_subpath=subpath + '/' if subpath else '',
|
||||
root_b64=root_b64,
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'pin_tu.html',
|
||||
mode='browse',
|
||||
images=image_files,
|
||||
videos=video_files,
|
||||
files=other_files,
|
||||
subfolders=subfolders,
|
||||
current_path_name=current_path_name,
|
||||
current_subpath=subpath + '/' if subpath else '',
|
||||
breadcrumbs=breadcrumbs,
|
||||
view_mode=mode,
|
||||
is_manga_path='漫画' in subpath,
|
||||
root_b64=root_b64,
|
||||
root_display=root_path,
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/file-info/<string:root_b64>')
|
||||
def file_info(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
file_path = request.args.get('p', '')
|
||||
if not file_path:
|
||||
abort(400)
|
||||
|
||||
root_abs = os.path.normpath(root_path)
|
||||
full_file = os.path.normpath(os.path.join(root_abs, file_path))
|
||||
if not is_safe_path(root_abs, full_file) or not os.path.isfile(full_file):
|
||||
abort(404)
|
||||
|
||||
stat = os.stat(full_file)
|
||||
size = stat.st_size
|
||||
if size < 1024:
|
||||
size_str = f'{size} B'
|
||||
elif size < 1024 * 1024:
|
||||
size_str = f'{size / 1024:.1f} KB'
|
||||
elif size < 1024 * 1024 * 1024:
|
||||
size_str = f'{size / (1024 * 1024):.1f} MB'
|
||||
else:
|
||||
size_str = f'{size / (1024 * 1024 * 1024):.2f} GB'
|
||||
|
||||
from datetime import datetime
|
||||
modified = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
||||
ext = os.path.splitext(file_path)[1].lstrip('.').upper()
|
||||
|
||||
return {
|
||||
'name': os.path.basename(file_path),
|
||||
'size': size_str,
|
||||
'size_bytes': size,
|
||||
'type': ext or '未知',
|
||||
'modified': modified,
|
||||
'path': file_path,
|
||||
}
|
||||
|
||||
|
||||
@bp.route('/media/<string:root_b64>')
|
||||
def serve_media(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
|
||||
file_path = request.args.get('p', '')
|
||||
if not file_path:
|
||||
abort(400)
|
||||
|
||||
root_abs = os.path.normpath(root_path)
|
||||
folder = os.path.dirname(file_path)
|
||||
filename = os.path.basename(file_path)
|
||||
base_dir = os.path.join(root_abs, folder)
|
||||
full_file = os.path.normpath(os.path.join(base_dir, filename))
|
||||
|
||||
if not is_safe_path(root_abs, full_file):
|
||||
abort(403)
|
||||
return send_from_directory(base_dir, filename)
|
||||
|
||||
|
||||
@bp.route('/rename/<string:root_b64>', methods=['POST'])
|
||||
def rename(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
|
||||
data = request.get_json() or {}
|
||||
old_path = data.get('path', '')
|
||||
new_name = data.get('new_name', '').strip()
|
||||
if not old_path or not new_name:
|
||||
return {'success': False, 'error': '参数不完整'}, 400
|
||||
|
||||
# 校验新文件名
|
||||
invalid = set(r'\/:*?"<>|')
|
||||
if any(c in invalid for c in new_name):
|
||||
return {'success': False, 'error': '文件名包含非法字符'}, 400
|
||||
|
||||
root_abs = os.path.normpath(root_path)
|
||||
old_full = os.path.normpath(os.path.join(root_abs, old_path))
|
||||
if not is_safe_path(root_abs, old_full) or not os.path.exists(old_full):
|
||||
return {'success': False, 'error': '文件不存在'}, 404
|
||||
|
||||
new_full = os.path.join(os.path.dirname(old_full), new_name)
|
||||
if os.path.exists(new_full) and os.path.normcase(new_full) != os.path.normcase(old_full):
|
||||
return {'success': False, 'error': '同名文件已存在'}, 400
|
||||
|
||||
try:
|
||||
os.rename(old_full, new_full)
|
||||
return {'success': True, 'new_name': new_name}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}, 500
|
||||
|
||||
|
||||
@bp.route('/search-page/<string:root_b64>')
|
||||
def search_page(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
return render_template('pin_tu_search.html', root_b64=root_b64, root_display=root_path, query=request.args.get('q', ''))
|
||||
|
||||
|
||||
@bp.route('/search/<string:root_b64>')
|
||||
def search(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
|
||||
query = request.args.get('q', '').strip().lower()
|
||||
if not query or len(query) < 1:
|
||||
return {'results': []}
|
||||
|
||||
root_abs = os.path.normpath(root_path)
|
||||
if not os.path.isdir(root_abs):
|
||||
abort(404)
|
||||
|
||||
results = []
|
||||
max_results = 50
|
||||
for dirpath, dirnames, filenames in os.walk(root_abs):
|
||||
# 搜索文件夹名
|
||||
for d in list(dirnames):
|
||||
if query in d.lower():
|
||||
rel = os.path.relpath(os.path.join(dirpath, d), root_abs).replace('\\', '/')
|
||||
results.append({'name': d, 'path': rel, 'type': 'folder'})
|
||||
if len(results) >= max_results:
|
||||
return {'results': results}
|
||||
# 搜索文件名
|
||||
for f in filenames:
|
||||
if query in f.lower():
|
||||
rel = os.path.relpath(os.path.join(dirpath, f), root_abs).replace('\\', '/')
|
||||
ext = os.path.splitext(f)[1].lstrip('.').upper()
|
||||
results.append({'name': f, 'path': rel, 'type': ext or 'file'})
|
||||
if len(results) >= max_results:
|
||||
return {'results': results}
|
||||
|
||||
return {'results': results}
|
||||
|
||||
|
||||
# ===== 搜索历史 API =====
|
||||
|
||||
@bp.route('/api/search-history/<string:root_b64>')
|
||||
def api_get_search_history(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
records = get_search_history(root_path, limit=20)
|
||||
return jsonify({'history': records})
|
||||
|
||||
|
||||
@bp.route('/api/search-history/<string:root_b64>', methods=['POST'])
|
||||
def api_add_search(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
data = request.get_json() or {}
|
||||
keyword = data.get('keyword', '').strip()
|
||||
result_count = data.get('result_count', 0)
|
||||
if keyword:
|
||||
add_search(keyword, root_path, result_count)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/api/search-history/<string:root_b64>', methods=['DELETE'])
|
||||
def api_delete_search_history(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
data = request.get_json() or {}
|
||||
if data.get('clear'):
|
||||
clear_search_history(root_path)
|
||||
else:
|
||||
ids = data.get('ids', [])
|
||||
if ids:
|
||||
delete_search_history(ids)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ===== 最近访问路径 API =====
|
||||
|
||||
@bp.route('/api/recent-paths')
|
||||
def api_get_recent_paths():
|
||||
records = get_recent_paths(limit=8)
|
||||
return jsonify({'paths': records})
|
||||
|
||||
|
||||
@bp.route('/api/recent-paths', methods=['POST'])
|
||||
def api_add_recent_path():
|
||||
data = request.get_json() or {}
|
||||
path = data.get('path', '').strip()
|
||||
if path:
|
||||
add_recent_path(path)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/api/recent-paths', methods=['DELETE'])
|
||||
def api_delete_recent_path():
|
||||
data = request.get_json() or {}
|
||||
if data.get('clear'):
|
||||
clear_recent_paths()
|
||||
else:
|
||||
path = data.get('path', '')
|
||||
if path:
|
||||
delete_recent_path(path)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ===== 新建文件夹 =====
|
||||
|
||||
@bp.route('/create-folder/<string:root_b64>', methods=['POST'])
|
||||
def create_folder(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
data = request.get_json() or {}
|
||||
subpath = data.get('path', '')
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return {'success': False, 'error': '名称不能为空'}, 400
|
||||
|
||||
invalid = set(r'\/:*?"<>|')
|
||||
if any(c in invalid for c in name):
|
||||
return {'success': False, 'error': '名称包含非法字符'}, 400
|
||||
|
||||
root_abs = os.path.normpath(root_path)
|
||||
if subpath:
|
||||
parent = os.path.normpath(os.path.join(root_abs, subpath))
|
||||
else:
|
||||
parent = root_abs
|
||||
if not is_safe_path(root_abs, parent) or not os.path.isdir(parent):
|
||||
return {'success': False, 'error': '父目录不存在'}, 400
|
||||
|
||||
target = os.path.join(parent, name)
|
||||
if os.path.exists(target):
|
||||
return {'success': False, 'error': '同名文件夹已存在'}, 400
|
||||
|
||||
try:
|
||||
os.makedirs(target)
|
||||
return {'success': True, 'name': name}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}, 500
|
||||
|
||||
|
||||
# ===== 移动文件/文件夹 =====
|
||||
|
||||
@bp.route('/move/<string:root_b64>', methods=['POST'])
|
||||
def move_item(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
data = request.get_json() or {}
|
||||
source = data.get('source', '')
|
||||
dest = data.get('dest', '') # 目标文件夹的相对路径,空字符串表示根目录
|
||||
if not source:
|
||||
return {'success': False, 'error': '缺少源路径'}, 400
|
||||
|
||||
root_abs = os.path.normpath(root_path)
|
||||
src_full = os.path.normpath(os.path.join(root_abs, source))
|
||||
if not is_safe_path(root_abs, src_full) or not os.path.exists(src_full):
|
||||
return {'success': False, 'error': '源文件不存在'}, 404
|
||||
|
||||
if dest:
|
||||
dest_dir = os.path.normpath(os.path.join(root_abs, dest))
|
||||
else:
|
||||
dest_dir = root_abs
|
||||
if not is_safe_path(root_abs, dest_dir) or not os.path.isdir(dest_dir):
|
||||
return {'success': False, 'error': '目标目录不存在'}, 400
|
||||
|
||||
# 不能移动到自身或自身子目录
|
||||
norm_src = os.path.normcase(src_full)
|
||||
norm_dest = os.path.normcase(dest_dir)
|
||||
if norm_dest.startswith(norm_src + os.sep) or norm_dest == norm_src:
|
||||
return {'success': False, 'error': '不能移动到自身或子目录'}, 400
|
||||
|
||||
item_name = os.path.basename(src_full)
|
||||
new_full = os.path.join(dest_dir, item_name)
|
||||
if os.path.exists(new_full) and os.path.normcase(new_full) != norm_src:
|
||||
return {'success': False, 'error': '目标位置已存在同名项目'}, 400
|
||||
|
||||
try:
|
||||
shutil.move(src_full, new_full)
|
||||
return {'success': True, 'name': item_name}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}, 500
|
||||
|
||||
|
||||
# ===== 删除文件/文件夹 =====
|
||||
|
||||
@bp.route('/delete/<string:root_b64>', methods=['POST'])
|
||||
def delete_item(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
data = request.get_json() or {}
|
||||
source = data.get('source', '')
|
||||
if not source:
|
||||
return {'success': False, 'error': '缺少路径'}, 400
|
||||
|
||||
root_abs = os.path.normpath(root_path)
|
||||
full = os.path.normpath(os.path.join(root_abs, source))
|
||||
if not is_safe_path(root_abs, full) or not os.path.exists(full):
|
||||
return {'success': False, 'error': '文件不存在'}, 404
|
||||
|
||||
name = os.path.basename(full)
|
||||
try:
|
||||
if os.path.isdir(full):
|
||||
shutil.rmtree(full)
|
||||
else:
|
||||
os.remove(full)
|
||||
return {'success': True, 'name': name}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}, 500
|
||||
|
||||
|
||||
# ===== 列出子文件夹(供移动弹窗使用)=====
|
||||
|
||||
@bp.route('/list-folders/<string:root_b64>')
|
||||
def list_folders(root_b64):
|
||||
root_path = _decode_root(root_b64)
|
||||
if not root_path:
|
||||
abort(400)
|
||||
subpath = request.args.get('p', '')
|
||||
|
||||
root_abs = os.path.normpath(root_path)
|
||||
if subpath:
|
||||
current = os.path.normpath(os.path.join(root_abs, subpath))
|
||||
else:
|
||||
current = root_abs
|
||||
|
||||
if not is_safe_path(root_abs, current) or not os.path.isdir(current):
|
||||
abort(404)
|
||||
|
||||
subfolders = get_subfolders(current)
|
||||
# 父目录
|
||||
parent = ''
|
||||
if subpath:
|
||||
parts = subpath.rstrip('/').split('/')
|
||||
parent = '/'.join(parts[:-1]) if len(parts) > 1 else ''
|
||||
|
||||
return jsonify({
|
||||
'current': subpath,
|
||||
'parent': parent,
|
||||
'folders': [{'name': f, 'path': (subpath + '/' + f).strip('/')} for f in subfolders]
|
||||
})
|
||||
9
flask-dev-api/blueprints/qr_code.py
Normal file
9
flask-dev-api/blueprints/qr_code.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint('qr_code', __name__, url_prefix='/qr-code')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('qr_code.html')
|
||||
415
flask-dev-api/blueprints/rvc.py
Normal file
415
flask-dev-api/blueprints/rvc.py
Normal file
@@ -0,0 +1,415 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
rvc RVC 变声蓝图
|
||||
通过 subprocess 调用 RVC 自带的 runtime\python.exe 实现语音变声
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import glob
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import tempfile
|
||||
from flask import Blueprint, render_template, request, jsonify, send_file, after_this_request
|
||||
|
||||
bp = Blueprint('rvc', __name__, url_prefix='/rvc')
|
||||
|
||||
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', 'rvc_config.json')
|
||||
WORKER_SCRIPT = os.path.join(BASE_DIR, 'utils', 'rvc_worker.py')
|
||||
|
||||
# 子进程管理
|
||||
_proc = None
|
||||
_proc_lock = threading.Lock()
|
||||
_proc_stderr = []
|
||||
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'device': None, 'model_loaded': False}
|
||||
|
||||
# 变声请求锁
|
||||
_synth_lock = threading.Lock()
|
||||
|
||||
# 任务结果缓存
|
||||
_task_results_local = {}
|
||||
|
||||
F0_METHODS = ['pm', 'harvest', 'crepe', 'rmvpe']
|
||||
F0_METHOD_NAMES = {
|
||||
'pm': 'PM (最快)', 'harvest': 'Harvest (高质量)',
|
||||
'crepe': 'Crepe (神经网络)', 'rmvpe': 'RMVPE (推荐)',
|
||||
}
|
||||
|
||||
|
||||
def _default_config():
|
||||
return {
|
||||
'rvc_project_path': r'E:\AI\RVC\RVC1006Nvidia',
|
||||
'model_dir': 'assets/weights',
|
||||
'model_dir_mode': 'relative',
|
||||
'index_dir': 'logs',
|
||||
'index_dir_mode': 'relative',
|
||||
'last_model': '',
|
||||
'last_index': '',
|
||||
'f0_up_key': 0,
|
||||
'f0_method': 'rmvpe',
|
||||
'index_rate': 0.75,
|
||||
'filter_radius': 3,
|
||||
'resample_sr': 0,
|
||||
'rms_mix_rate': 0.25,
|
||||
'protect': 0.33,
|
||||
}
|
||||
|
||||
|
||||
def _load_config():
|
||||
cfg = _default_config()
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
saved = json.load(f)
|
||||
cfg.update(saved)
|
||||
# 空值回退到默认值
|
||||
if not cfg.get('model_dir'):
|
||||
cfg['model_dir'] = 'assets/weights'
|
||||
if not cfg.get('index_dir'):
|
||||
cfg['index_dir'] = 'logs'
|
||||
if not cfg.get('model_dir_mode'):
|
||||
cfg['model_dir_mode'] = 'relative'
|
||||
if not cfg.get('index_dir_mode'):
|
||||
cfg['index_dir_mode'] = 'relative'
|
||||
return cfg
|
||||
|
||||
|
||||
def _save_config(cfg):
|
||||
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _scan_pth_models(directory):
|
||||
"""扫描 .pth 模型文件"""
|
||||
if not directory or not os.path.isdir(directory):
|
||||
return []
|
||||
files = glob.glob(os.path.join(directory, '*.pth'))
|
||||
files.sort(key=lambda x: os.path.basename(x).lower())
|
||||
return [os.path.basename(f) for f in files]
|
||||
|
||||
|
||||
def _scan_index_files(directory):
|
||||
"""递归扫描 .index 文件,排除 trained"""
|
||||
if not directory or not os.path.isdir(directory):
|
||||
return []
|
||||
results = []
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for f in files:
|
||||
if f.endswith('.index') and 'trained' not in f.lower():
|
||||
results.append(os.path.join(root, f))
|
||||
results.sort(key=lambda x: os.path.basename(x).lower())
|
||||
return results
|
||||
|
||||
|
||||
def _send_msg(proc, obj):
|
||||
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||
proc.stdin.write(struct.pack('<I', len(data)))
|
||||
proc.stdin.write(data)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def _recv_msg(proc):
|
||||
raw_len = proc.stdout.read(4)
|
||||
if not raw_len:
|
||||
return None
|
||||
msg_len = struct.unpack('<I', raw_len)[0]
|
||||
data = proc.stdout.read(msg_len)
|
||||
return json.loads(data.decode('utf-8'))
|
||||
|
||||
|
||||
def _init_engine():
|
||||
global _proc, _engine_status
|
||||
if _engine_status['initialized'] and _proc and _proc.poll() is None:
|
||||
return True, None
|
||||
|
||||
with _proc_lock:
|
||||
if _engine_status['initialized'] and _proc and _proc.poll() is None:
|
||||
return True, None
|
||||
if _engine_status['loading']:
|
||||
return False, '引擎正在加载中,请稍候...'
|
||||
|
||||
_engine_status['loading'] = True
|
||||
_engine_status['error'] = None
|
||||
|
||||
cfg = _load_config()
|
||||
rvc_path = cfg.get('rvc_project_path', '').strip()
|
||||
|
||||
if not rvc_path or not os.path.isdir(rvc_path):
|
||||
_engine_status['loading'] = False
|
||||
_engine_status['error'] = f'RVC 项目路径不存在: {rvc_path}'
|
||||
return False, _engine_status['error']
|
||||
|
||||
runtime_python = os.path.join(rvc_path, 'runtime', 'python.exe')
|
||||
if not os.path.exists(runtime_python):
|
||||
_engine_status['loading'] = False
|
||||
_engine_status['error'] = f'找不到 RVC 运行时: {runtime_python}'
|
||||
return False, _engine_status['error']
|
||||
|
||||
try:
|
||||
creationflags = 0x08000000 if os.name == 'nt' else 0
|
||||
_proc = subprocess.Popen(
|
||||
[runtime_python, WORKER_SCRIPT, rvc_path],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
_proc_stderr.clear()
|
||||
def _read_stderr():
|
||||
try:
|
||||
for line in _proc.stderr:
|
||||
_proc_stderr.append(line.decode('utf-8', errors='replace').rstrip())
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=_read_stderr, daemon=True).start()
|
||||
|
||||
msg = _recv_msg(_proc)
|
||||
if msg and msg.get('type') == 'ready':
|
||||
_engine_status['initialized'] = True
|
||||
_engine_status['device'] = msg.get('device', 'unknown')
|
||||
_engine_status['error'] = None
|
||||
return True, None
|
||||
else:
|
||||
err = msg.get('error', '未知错误') if msg else '子进程无响应'
|
||||
if _proc_stderr:
|
||||
err += '\n--- 日志 ---\n' + '\n'.join(_proc_stderr[-10:])
|
||||
_engine_status['error'] = err
|
||||
_kill_proc()
|
||||
return False, err
|
||||
|
||||
except Exception as e:
|
||||
_engine_status['error'] = str(e)
|
||||
_kill_proc()
|
||||
return False, str(e)
|
||||
finally:
|
||||
_engine_status['loading'] = False
|
||||
|
||||
|
||||
def _kill_proc():
|
||||
global _proc
|
||||
if _proc:
|
||||
try:
|
||||
_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_proc = None
|
||||
|
||||
|
||||
def _send_command(obj):
|
||||
with _synth_lock:
|
||||
if not _proc or _proc.poll() is not None:
|
||||
return False, '子进程未运行'
|
||||
try:
|
||||
_send_msg(_proc, obj)
|
||||
msg = _recv_msg(_proc)
|
||||
if msg and msg.get('type') == 'done':
|
||||
return True, msg
|
||||
else:
|
||||
return False, msg.get('error', '未知错误') if msg else '子进程无响应'
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
# ==================== 路由 ====================
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
cfg = _load_config()
|
||||
return render_template('rvc.html',
|
||||
config=cfg, f0_methods=F0_METHODS,
|
||||
f0_method_names=F0_METHOD_NAMES)
|
||||
|
||||
|
||||
@bp.route('/config', methods=['GET'])
|
||||
def get_config():
|
||||
cfg = _load_config()
|
||||
cfg['engine_status'] = _engine_status
|
||||
# 扫描模型
|
||||
rvc_path = cfg.get('rvc_project_path', '')
|
||||
model_dir_raw = cfg.get('model_dir', 'assets/weights')
|
||||
model_dir = model_dir_raw if cfg.get('model_dir_mode') == 'absolute' else os.path.join(rvc_path, model_dir_raw)
|
||||
cfg['models'] = _scan_pth_models(model_dir)
|
||||
# 扫描 index
|
||||
index_dir_raw = cfg.get('index_dir', 'logs')
|
||||
index_dir = index_dir_raw if cfg.get('index_dir_mode') == 'absolute' else os.path.join(rvc_path, index_dir_raw)
|
||||
cfg['index_files'] = _scan_index_files(index_dir)
|
||||
return jsonify(cfg)
|
||||
|
||||
|
||||
@bp.route('/config', methods=['POST'])
|
||||
def save_config():
|
||||
data = request.get_json()
|
||||
cfg = _load_config()
|
||||
for key in ('rvc_project_path', 'model_dir', 'model_dir_mode', 'index_dir', 'index_dir_mode',
|
||||
'last_model', 'last_index',
|
||||
'f0_up_key', 'f0_method', 'index_rate',
|
||||
'filter_radius', 'resample_sr', 'rms_mix_rate', 'protect'):
|
||||
if key in data:
|
||||
cfg[key] = data[key]
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/upload-audio', methods=['POST'])
|
||||
def upload_audio():
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({'success': False, 'error': '请上传音频文件'}), 400
|
||||
audio_file = request.files['audio']
|
||||
if not audio_file.filename:
|
||||
return jsonify({'success': False, 'error': '未选择文件'}), 400
|
||||
ext = os.path.splitext(audio_file.filename)[1].lower()
|
||||
if ext not in ('.wav', '.mp3', '.flac', '.ogg', '.m4a', '.aac'):
|
||||
return jsonify({'success': False, 'error': f'不支持的音频格式: {ext}'}), 400
|
||||
orig_filename = audio_file.filename
|
||||
filename = uuid.uuid4().hex + ext
|
||||
save_path = os.path.join(tempfile.gettempdir(), filename)
|
||||
audio_file.save(save_path)
|
||||
return jsonify({'success': True, 'path': save_path, 'filename': orig_filename})
|
||||
|
||||
|
||||
@bp.route('/engine-status', methods=['GET'])
|
||||
def engine_status():
|
||||
status = dict(_engine_status)
|
||||
status['logs'] = _proc_stderr[-50:]
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@bp.route('/clear-logs', methods=['POST'])
|
||||
def clear_logs():
|
||||
_proc_stderr.clear()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/init-engine', methods=['POST'])
|
||||
def init_engine():
|
||||
ok, err = _init_engine()
|
||||
if ok:
|
||||
return jsonify({'success': True, 'device': _engine_status.get('device')})
|
||||
return jsonify({'success': False, 'error': err})
|
||||
|
||||
|
||||
@bp.route('/reload-engine', methods=['POST'])
|
||||
def reload_engine():
|
||||
global _engine_status
|
||||
_kill_proc()
|
||||
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'device': None, 'model_loaded': False}
|
||||
ok, err = _init_engine()
|
||||
if ok:
|
||||
return jsonify({'success': True, 'device': _engine_status.get('device')})
|
||||
return jsonify({'success': False, 'error': err})
|
||||
|
||||
|
||||
@bp.route('/stop-engine', methods=['POST'])
|
||||
def stop_engine():
|
||||
global _engine_status
|
||||
_kill_proc()
|
||||
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'device': None, 'model_loaded': False}
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/switch-model', methods=['POST'])
|
||||
def switch_model():
|
||||
if not _engine_status['initialized']:
|
||||
return jsonify({'success': False, 'error': '引擎未初始化'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
model_name = data.get('model_name')
|
||||
if not model_name:
|
||||
return jsonify({'success': False, 'error': '缺少模型名称'}), 400
|
||||
|
||||
ok, result = _send_command({'type': 'load_model', 'model_name': model_name})
|
||||
if ok:
|
||||
_engine_status['model_loaded'] = True
|
||||
cfg = _load_config()
|
||||
cfg['last_model'] = model_name
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True, 'message': f'模型已加载: {model_name}'})
|
||||
return jsonify({'success': False, 'error': result})
|
||||
|
||||
|
||||
@bp.route('/convert', methods=['POST'])
|
||||
def convert():
|
||||
if not _engine_status['initialized']:
|
||||
return jsonify({'success': False, 'error': '引擎未初始化,请先初始化'}), 400
|
||||
if not _engine_status.get('model_loaded'):
|
||||
return jsonify({'success': False, 'error': '请先加载语音模型'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
input_path = (data.get('input_path') or '').strip()
|
||||
if not input_path or not os.path.exists(input_path):
|
||||
return jsonify({'success': False, 'error': '请上传有效的音频文件'}), 400
|
||||
|
||||
cfg = _load_config()
|
||||
for key in ('last_model', 'last_index', 'f0_up_key', 'f0_method',
|
||||
'index_rate', 'filter_radius', 'resample_sr', 'rms_mix_rate', 'protect'):
|
||||
if key in data:
|
||||
cfg[key] = data[key]
|
||||
_save_config(cfg)
|
||||
|
||||
task_id = uuid.uuid4().hex
|
||||
output_path = os.path.join(tempfile.gettempdir(), f'rvc_{task_id}.wav')
|
||||
|
||||
convert_params = {
|
||||
'type': 'convert',
|
||||
'input_path': input_path,
|
||||
'output_path': output_path,
|
||||
'f0_up_key': data.get('f0_up_key', cfg['f0_up_key']),
|
||||
'f0_method': data.get('f0_method', cfg['f0_method']),
|
||||
'file_index': data.get('file_index', ''),
|
||||
'index_rate': data.get('index_rate', cfg['index_rate']),
|
||||
'filter_radius': data.get('filter_radius', cfg['filter_radius']),
|
||||
'resample_sr': data.get('resample_sr', cfg['resample_sr']),
|
||||
'rms_mix_rate': data.get('rms_mix_rate', cfg['rms_mix_rate']),
|
||||
'protect': data.get('protect', cfg['protect']),
|
||||
}
|
||||
|
||||
def _do_convert():
|
||||
ok, result = _send_command(convert_params)
|
||||
if ok:
|
||||
_task_results_local[task_id] = {'status': 'done', 'file': output_path}
|
||||
else:
|
||||
_task_results_local[task_id] = {'status': 'error', 'error': result}
|
||||
|
||||
_task_results_local[task_id] = {'status': 'converting'}
|
||||
t = threading.Thread(target=_do_convert, daemon=True)
|
||||
t.start()
|
||||
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
|
||||
|
||||
@bp.route('/status/<task_id>')
|
||||
def task_status(task_id):
|
||||
info = _task_results_local.get(task_id)
|
||||
if not info:
|
||||
return jsonify({'success': False, 'error': '任务不存在'}), 404
|
||||
|
||||
resp = {'success': True, 'progress': info}
|
||||
if info['status'] == 'done':
|
||||
resp['audio_url'] = f'/rvc/audio/{task_id}'
|
||||
elif info['status'] == 'error':
|
||||
resp['progress'] = {'status': 'error', 'error': info.get('error', '未知错误')}
|
||||
_task_results_local.pop(task_id, None)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@bp.route('/audio/<task_id>')
|
||||
def get_audio(task_id):
|
||||
info = _task_results_local.pop(task_id, None)
|
||||
audio_path = info['file'] if info else os.path.join(tempfile.gettempdir(), f'rvc_{task_id}.wav')
|
||||
if not os.path.exists(audio_path):
|
||||
return jsonify({'error': '音频文件不存在'}), 404
|
||||
|
||||
@after_this_request
|
||||
def cleanup(resp):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except OSError:
|
||||
pass
|
||||
return resp
|
||||
|
||||
return send_file(audio_path, mimetype='audio/wav')
|
||||
342
flask-dev-api/blueprints/sovits_tts.py
Normal file
342
flask-dev-api/blueprints/sovits_tts.py
Normal file
@@ -0,0 +1,342 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
sovits-tts GPT-SoVITS v2 配音蓝图
|
||||
代理转发请求到 GPT-SoVITS v2 API 服务
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import glob
|
||||
import platform
|
||||
import subprocess
|
||||
import requests
|
||||
from flask import Blueprint, render_template, request, jsonify, Response
|
||||
|
||||
bp = Blueprint('sovits_tts', __name__, url_prefix='/sovits-tts')
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config', 'sovits_config.json')
|
||||
try:
|
||||
from config import BASE_DIR
|
||||
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'sovits_config.json')
|
||||
except ImportError:
|
||||
pass
|
||||
_sovits_proc = None
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('sovits_tts.html')
|
||||
|
||||
|
||||
def _load_config():
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return {
|
||||
'api_url': 'http://127.0.0.1:9880',
|
||||
'gpt_model_dir': '', 'sovits_model_dir': '',
|
||||
'last_gpt_model': '', 'last_sovits_model': '',
|
||||
'sovits_path': '', 'start_cmd': 'start api_v2.bat',
|
||||
'refer_audio_history': [],
|
||||
'refer_audio_folder': '',
|
||||
'top_k': 5, 'top_p': 1.0, 'temperature': 1.0,
|
||||
'batch_size': 32, 'speed': 1.0, 'text_split_method': 'cut5'
|
||||
}
|
||||
|
||||
|
||||
def _save_config(cfg):
|
||||
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _scan_models(model_dir, ext):
|
||||
if not model_dir or not os.path.isdir(model_dir):
|
||||
return []
|
||||
files = glob.glob(os.path.join(model_dir, '**', f'*{ext}'), recursive=True)
|
||||
files.sort(key=lambda f: os.path.getmtime(f), reverse=True)
|
||||
return files
|
||||
|
||||
|
||||
@bp.route('/config', methods=['GET'])
|
||||
def get_config():
|
||||
cfg = _load_config()
|
||||
cfg['gpt_models'] = _scan_models(cfg.get('gpt_model_dir', ''), '.ckpt')
|
||||
cfg['sovits_models'] = _scan_models(cfg.get('sovits_model_dir', ''), '.pth')
|
||||
return jsonify(cfg)
|
||||
|
||||
|
||||
@bp.route('/config', methods=['POST'])
|
||||
def save_config():
|
||||
data = request.get_json()
|
||||
cfg = _load_config()
|
||||
for key in ('api_url', 'gpt_model_dir', 'sovits_model_dir', 'last_gpt_model', 'last_sovits_model', 'sovits_path', 'start_cmd',
|
||||
'top_k', 'top_p', 'temperature', 'batch_size', 'speed', 'text_split_method', 'refer_audio_folder'):
|
||||
if key in data:
|
||||
cfg[key] = data[key]
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/refer-audio-history', methods=['POST'])
|
||||
def save_refer_audio():
|
||||
data = request.get_json()
|
||||
path = (data.get('path') or '').strip()
|
||||
if not path:
|
||||
return jsonify({'success': False, 'error': '路径不能为空'})
|
||||
cfg = _load_config()
|
||||
history = cfg.get('refer_audio_history', [])
|
||||
if path in history:
|
||||
history.remove(path)
|
||||
history.insert(0, path)
|
||||
cfg['refer_audio_history'] = history
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True, 'history': history})
|
||||
|
||||
|
||||
@bp.route('/refer-audio-history', methods=['DELETE'])
|
||||
def delete_refer_audio():
|
||||
data = request.get_json()
|
||||
path = (data.get('path') or '').strip()
|
||||
cfg = _load_config()
|
||||
history = cfg.get('refer_audio_history', [])
|
||||
if path in history:
|
||||
history.remove(path)
|
||||
cfg['refer_audio_history'] = history
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True, 'history': history})
|
||||
|
||||
|
||||
@bp.route('/refer-audio-history', methods=['PUT'])
|
||||
def update_refer_audio():
|
||||
data = request.get_json()
|
||||
old_path = (data.get('old_path') or '').strip()
|
||||
new_path = (data.get('new_path') or '').strip()
|
||||
if not old_path or not new_path:
|
||||
return jsonify({'success': False, 'error': '路径不能为空'})
|
||||
cfg = _load_config()
|
||||
history = cfg.get('refer_audio_history', [])
|
||||
if old_path not in history:
|
||||
return jsonify({'success': False, 'error': '原路径不存在'})
|
||||
if new_path in history and new_path != old_path:
|
||||
return jsonify({'success': False, 'error': '新路径已存在'})
|
||||
idx = history.index(old_path)
|
||||
history[idx] = new_path
|
||||
cfg['refer_audio_history'] = history
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True, 'history': history})
|
||||
|
||||
|
||||
@bp.route('/scan-audio-folder', methods=['POST'])
|
||||
def scan_audio_folder():
|
||||
"""扫描文件夹下的音频文件"""
|
||||
data = request.get_json()
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请输入文件夹路径'})
|
||||
if not os.path.isdir(folder):
|
||||
return jsonify({'success': False, 'error': '文件夹不存在'})
|
||||
|
||||
AUDIO_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.aac', '.m4a', '.wma')
|
||||
files = []
|
||||
for f in os.listdir(folder):
|
||||
full = os.path.join(folder, f)
|
||||
if os.path.isfile(full) and f.lower().endswith(AUDIO_EXTS):
|
||||
files.append(full)
|
||||
files.sort(key=lambda x: os.path.basename(x).lower())
|
||||
|
||||
# 保存最后使用的文件夹
|
||||
cfg = _load_config()
|
||||
cfg['refer_audio_folder'] = folder
|
||||
_save_config(cfg)
|
||||
|
||||
return jsonify({'success': True, 'files': files, 'count': len(files)})
|
||||
|
||||
|
||||
@bp.route('/test-connection', methods=['POST'])
|
||||
def test_connection():
|
||||
data = request.get_json()
|
||||
api_url = (data.get('api_url') or '').strip().rstrip('/')
|
||||
if not api_url:
|
||||
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
|
||||
try:
|
||||
r = requests.get(api_url + '/test', timeout=10)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return jsonify({'success': True, 'message': data.get('message', '连接成功')})
|
||||
return jsonify({'success': False, 'error': f'服务返回状态码 {r.status_code}'})
|
||||
except requests.exceptions.ConnectionError:
|
||||
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
|
||||
except requests.exceptions.Timeout:
|
||||
return jsonify({'success': False, 'error': '连接超时'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@bp.route('/run', methods=['POST'])
|
||||
def run_api():
|
||||
global _sovits_proc
|
||||
if platform.system() != 'Windows':
|
||||
return jsonify({'success': False, 'error': '仅支持 Windows 系统'})
|
||||
cfg = _load_config()
|
||||
sovits_path = (cfg.get('sovits_path') or '').strip()
|
||||
start_cmd = (cfg.get('start_cmd') or '').strip()
|
||||
if not sovits_path:
|
||||
return jsonify({'success': False, 'error': '请先配置 GPT-SoVITS 路径'})
|
||||
if not os.path.isdir(sovits_path):
|
||||
return jsonify({'success': False, 'error': f'路径不存在: {sovits_path}'})
|
||||
if not start_cmd:
|
||||
return jsonify({'success': False, 'error': '请先配置启动命令'})
|
||||
try:
|
||||
_sovits_proc = subprocess.Popen(start_cmd, shell=True, cwd=sovits_path)
|
||||
return jsonify({'success': True, 'message': f'已启动: {start_cmd}'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'启动失败: {str(e)}'})
|
||||
|
||||
|
||||
@bp.route('/status', methods=['GET'])
|
||||
def check_status():
|
||||
cfg = _load_config()
|
||||
api_url = (cfg.get('api_url') or '').strip().rstrip('/')
|
||||
if not api_url:
|
||||
return jsonify({'running': False})
|
||||
try:
|
||||
r = requests.get(api_url + '/test', timeout=3)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return jsonify({'running': True, 'message': data.get('message', '')})
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'running': False})
|
||||
|
||||
|
||||
@bp.route('/kill', methods=['POST'])
|
||||
def kill_api():
|
||||
global _sovits_proc
|
||||
if _sovits_proc and _sovits_proc.poll() is None:
|
||||
try:
|
||||
subprocess.call(['taskkill', '/F', '/T', '/PID', str(_sovits_proc.pid)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except Exception:
|
||||
pass
|
||||
_sovits_proc = None
|
||||
else:
|
||||
cfg = _load_config()
|
||||
api_url = (cfg.get('api_url') or '').strip().rstrip('/')
|
||||
if api_url:
|
||||
try:
|
||||
r = requests.get(api_url + '/control?command=exit', timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'success': True, 'message': '已发送停止信号'})
|
||||
|
||||
|
||||
@bp.route('/synthesize', methods=['POST'])
|
||||
def synthesize():
|
||||
data = request.get_json()
|
||||
api_url = (data.get('api_url') or '').strip().rstrip('/')
|
||||
text = (data.get('text') or '').strip()
|
||||
text_lang = (data.get('text_lang') or 'zh').strip()
|
||||
|
||||
if not api_url:
|
||||
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
|
||||
if not text:
|
||||
return jsonify({'success': False, 'error': '请输入要合成的文本'})
|
||||
|
||||
ref_audio_path = (data.get('ref_audio_path') or '').strip()
|
||||
prompt_text = (data.get('prompt_text') or '').strip()
|
||||
prompt_lang = (data.get('prompt_lang') or 'zh').strip()
|
||||
|
||||
if not ref_audio_path:
|
||||
return jsonify({'success': False, 'error': '请填写参考音频路径'})
|
||||
|
||||
payload = {
|
||||
'text': text,
|
||||
'text_lang': text_lang,
|
||||
'ref_audio_path': ref_audio_path,
|
||||
'prompt_text': prompt_text,
|
||||
'prompt_lang': prompt_lang,
|
||||
'text_split_method': (data.get('text_split_method') or 'cut5').strip(),
|
||||
'batch_size': data.get('batch_size', 1),
|
||||
'top_k': data.get('top_k', 5),
|
||||
'top_p': data.get('top_p', 1.0),
|
||||
'temperature': data.get('temperature', 1.0),
|
||||
'speed_factor': data.get('speed_factor', 1.0),
|
||||
'seed': data.get('seed', -1),
|
||||
'media_type': 'wav',
|
||||
'streaming_mode': False,
|
||||
}
|
||||
|
||||
try:
|
||||
r = requests.post(api_url + '/tts', json=payload, timeout=300)
|
||||
content_type = r.headers.get('Content-Type', '')
|
||||
|
||||
if r.status_code == 200 and 'audio' in content_type:
|
||||
return Response(r.content, mimetype='audio/wav',
|
||||
headers={'Content-Disposition': 'inline'})
|
||||
else:
|
||||
try:
|
||||
err = r.json()
|
||||
msg = err.get('message', r.text[:200])
|
||||
except Exception:
|
||||
msg = r.text[:200]
|
||||
return jsonify({'success': False, 'error': f'合成失败: {msg}'})
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
|
||||
except requests.exceptions.Timeout:
|
||||
return jsonify({'success': False, 'error': '合成超时(300秒),文本可能过长'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'请求失败: {str(e)}'})
|
||||
|
||||
|
||||
@bp.route('/set-gpt-model', methods=['POST'])
|
||||
def set_gpt_model():
|
||||
data = request.get_json()
|
||||
api_url = (data.get('api_url') or '').strip().rstrip('/')
|
||||
weights_path = (data.get('weights_path') or '').strip()
|
||||
|
||||
if not api_url:
|
||||
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
|
||||
if not weights_path:
|
||||
return jsonify({'success': False, 'error': '请选择 GPT 模型'})
|
||||
|
||||
try:
|
||||
r = requests.get(api_url + '/set_gpt_weights', params={'weights_path': weights_path}, timeout=60)
|
||||
if r.status_code == 200:
|
||||
return jsonify({'success': True, 'message': 'GPT 模型切换成功'})
|
||||
try:
|
||||
err = r.json()
|
||||
msg = err.get('message', r.text[:200])
|
||||
except Exception:
|
||||
msg = r.text[:200]
|
||||
return jsonify({'success': False, 'error': f'切换失败: {msg}'})
|
||||
except requests.exceptions.ConnectionError:
|
||||
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@bp.route('/set-sovits-model', methods=['POST'])
|
||||
def set_sovits_model():
|
||||
data = request.get_json()
|
||||
api_url = (data.get('api_url') or '').strip().rstrip('/')
|
||||
weights_path = (data.get('weights_path') or '').strip()
|
||||
|
||||
if not api_url:
|
||||
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
|
||||
if not weights_path:
|
||||
return jsonify({'success': False, 'error': '请选择 SoVITS 模型'})
|
||||
|
||||
try:
|
||||
r = requests.get(api_url + '/set_sovits_weights', params={'weights_path': weights_path}, timeout=60)
|
||||
if r.status_code == 200:
|
||||
return jsonify({'success': True, 'message': 'SoVITS 模型切换成功'})
|
||||
try:
|
||||
err = r.json()
|
||||
msg = err.get('message', r.text[:200])
|
||||
except Exception:
|
||||
msg = r.text[:200]
|
||||
return jsonify({'success': False, 'error': f'切换失败: {msg}'})
|
||||
except requests.exceptions.ConnectionError:
|
||||
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
338
flask-dev-api/blueprints/stt.py
Normal file
338
flask-dev-api/blueprints/stt.py
Normal file
@@ -0,0 +1,338 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
STT 语音识别蓝图
|
||||
使用 faster-whisper 实现音频转文字
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
bp = Blueprint('stt', __name__, url_prefix='/stt')
|
||||
|
||||
# HuggingFace 镜像
|
||||
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
|
||||
|
||||
# OpenCC 繁体转简体
|
||||
try:
|
||||
from opencc import OpenCC
|
||||
_cc_t2s = OpenCC('t2s')
|
||||
except Exception:
|
||||
_cc_t2s = None
|
||||
|
||||
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', 'stt_config.json')
|
||||
|
||||
# 模型缓存
|
||||
_model_cache = {}
|
||||
_task_queue = []
|
||||
_task_progress = {}
|
||||
_task_results = {}
|
||||
_worker_started = False
|
||||
_worker_lock = threading.Lock()
|
||||
|
||||
LANGUAGES = {
|
||||
'auto': '自动检测',
|
||||
'zh': '中文', 'en': '英语', 'ja': '日语', 'ko': '韩语',
|
||||
'fr': '法语', 'de': '德语', 'es': '西班牙语', 'ru': '俄语',
|
||||
'th': '泰语', 'it': '意大利语', 'pt': '葡萄牙语', 'vi': '越南语',
|
||||
'ar': '阿拉伯语', 'tr': '土耳其语',
|
||||
}
|
||||
|
||||
|
||||
def _load_config():
|
||||
cfg = {'model_dir': ''}
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
cfg.update(json.load(f))
|
||||
return cfg
|
||||
|
||||
|
||||
def _save_config(cfg):
|
||||
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _scan_models(directory):
|
||||
"""扫描模型目录,提取已下载的模型名称"""
|
||||
models = []
|
||||
if not directory or not os.path.isdir(directory):
|
||||
return models
|
||||
prefix = 'models--Systran--faster-whisper-'
|
||||
for name in os.listdir(directory):
|
||||
full = os.path.join(directory, name)
|
||||
if os.path.isdir(full) and name.startswith(prefix):
|
||||
model_name = name[len(prefix):]
|
||||
if model_name:
|
||||
models.append(model_name)
|
||||
return sorted(models)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
cfg = _load_config()
|
||||
return render_template('stt.html', models=_scan_models(cfg['model_dir']), languages=LANGUAGES)
|
||||
|
||||
|
||||
@bp.route('/config', methods=['GET'])
|
||||
def get_config():
|
||||
cfg = _load_config()
|
||||
cfg['models'] = _scan_models(cfg['model_dir'])
|
||||
return jsonify(cfg)
|
||||
|
||||
|
||||
@bp.route('/config', methods=['POST'])
|
||||
def save_config():
|
||||
data = request.get_json()
|
||||
cfg = _load_config()
|
||||
if 'model_dir' in data:
|
||||
cfg['model_dir'] = data['model_dir'].strip()
|
||||
_save_config(cfg)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/models')
|
||||
def list_models():
|
||||
cfg = _load_config()
|
||||
return jsonify({'models': _scan_models(cfg['model_dir'])})
|
||||
|
||||
|
||||
@bp.route('/cuda-check')
|
||||
def cuda_check():
|
||||
"""检查 CUDA 是否可用(ctranslate2 优先,PyTorch 兜底)"""
|
||||
# 方法1: ctranslate2(faster-whisper 的实际后端)
|
||||
try:
|
||||
import ctranslate2
|
||||
count = ctranslate2.get_cuda_device_count()
|
||||
if count > 0:
|
||||
name = 'CUDA Device'
|
||||
try:
|
||||
name = ctranslate2.get_cuda_device_name(0) or name
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'cuda': True, 'device_count': count, 'name': name})
|
||||
except Exception:
|
||||
pass
|
||||
# 方法2: PyTorch
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
return jsonify({'cuda': True, 'device_count': torch.cuda.device_count(),
|
||||
'name': torch.cuda.get_device_name(0)})
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'cuda': False})
|
||||
|
||||
|
||||
def _ms_to_srt_time(ms):
|
||||
td = timedelta(milliseconds=ms)
|
||||
h, rem = divmod(td.seconds, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
ms_part = td.microseconds // 1000
|
||||
return f'{h:02d}:{m:02d}:{s:02d},{ms_part:03d}'
|
||||
|
||||
|
||||
def _convert_to_wav(input_path):
|
||||
"""用 FFmpeg 转为 16kHz 单声道 WAV,返回 wav 路径或 None"""
|
||||
wav_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + '.wav')
|
||||
cmd = ['ffmpeg', '-y', '-i', input_path, '-ar', '16000', '-ac', '1', wav_path]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300,
|
||||
creationflags=0x08000000 if os.name == 'nt' else 0)
|
||||
if result.returncode == 0 and os.path.exists(wav_path):
|
||||
return wav_path
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_model(model_name, device='cpu'):
|
||||
"""获取或加载模型,CUDA 失败自动回退 CPU"""
|
||||
cfg = _load_config()
|
||||
model_dir = cfg['model_dir']
|
||||
cache_key = f'{model_name}_{device}_{model_dir}'
|
||||
if cache_key not in _model_cache:
|
||||
from faster_whisper import WhisperModel
|
||||
try:
|
||||
_model_cache[cache_key] = WhisperModel(
|
||||
model_name, device=device, download_root=model_dir
|
||||
)
|
||||
except Exception as e:
|
||||
if device == 'cuda':
|
||||
# CUDA 加载失败,回退 CPU
|
||||
cpu_key = f'{model_name}_cpu_{model_dir}'
|
||||
if cpu_key not in _model_cache:
|
||||
_model_cache[cpu_key] = WhisperModel(
|
||||
model_name, device='cpu', download_root=model_dir
|
||||
)
|
||||
return _model_cache[cpu_key], f'CUDA 加载失败({e}),已回退到 CPU'
|
||||
raise
|
||||
return _model_cache[cache_key], None
|
||||
|
||||
|
||||
def _process_task(task):
|
||||
"""处理单个转录任务"""
|
||||
task_id = task['task_id']
|
||||
fmt = task.get('format', 'text')
|
||||
device = task.get('device', 'cpu')
|
||||
try:
|
||||
_task_progress[task_id] = {'percent': 0, 'status': 'loading', 'format': fmt}
|
||||
|
||||
model, warn = _get_model(task['model'], device)
|
||||
if warn:
|
||||
_task_progress[task_id] = {'percent': 0, 'status': 'transcribing', 'format': fmt, 'warning': warn}
|
||||
else:
|
||||
_task_progress[task_id] = {'percent': 0, 'status': 'transcribing', 'format': fmt}
|
||||
|
||||
lang = task['language'] if task['language'] != 'auto' else None
|
||||
segments, info = model.transcribe(
|
||||
task['wav_path'],
|
||||
beam_size=5, best_of=5,
|
||||
vad_filter=True,
|
||||
language=lang,
|
||||
)
|
||||
|
||||
total_duration = max(info.duration, 0.01)
|
||||
results = []
|
||||
|
||||
for seg in segments:
|
||||
_task_progress[task_id] = {
|
||||
'percent': round(seg.end / total_duration, 2),
|
||||
'status': 'transcribing',
|
||||
'format': fmt,
|
||||
}
|
||||
text = seg.text.strip()
|
||||
if not text or len(text) <= 1:
|
||||
continue
|
||||
if _cc_t2s:
|
||||
text = _cc_t2s.convert(text)
|
||||
|
||||
start_ms = int(seg.start * 1000)
|
||||
end_ms = int(seg.end * 1000)
|
||||
|
||||
results.append({
|
||||
'start': start_ms,
|
||||
'end': end_ms,
|
||||
'start_time': _ms_to_srt_time(start_ms),
|
||||
'end_time': _ms_to_srt_time(end_ms),
|
||||
'text': text,
|
||||
})
|
||||
|
||||
_task_results[task_id] = results
|
||||
_task_progress[task_id] = {'percent': 1, 'status': 'done', 'format': fmt}
|
||||
|
||||
except Exception as e:
|
||||
_task_progress[task_id] = {'percent': 0, 'status': 'error', 'error': str(e), 'format': fmt}
|
||||
finally:
|
||||
# 清理临时文件
|
||||
try:
|
||||
if os.path.exists(task['wav_path']):
|
||||
os.remove(task['wav_path'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _worker():
|
||||
"""后台 worker 线程"""
|
||||
while True:
|
||||
if not _task_queue:
|
||||
time.sleep(1)
|
||||
continue
|
||||
task = _task_queue.pop(0)
|
||||
_process_task(task)
|
||||
|
||||
|
||||
def _ensure_worker():
|
||||
global _worker_started
|
||||
if not _worker_started:
|
||||
with _worker_lock:
|
||||
if not _worker_started:
|
||||
t = threading.Thread(target=_worker, daemon=True)
|
||||
t.start()
|
||||
_worker_started = True
|
||||
|
||||
|
||||
@bp.route('/transcribe', methods=['POST'])
|
||||
def transcribe():
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({'success': False, 'error': '请上传音频文件'}), 400
|
||||
|
||||
audio_file = request.files['audio']
|
||||
if not audio_file.filename:
|
||||
return jsonify({'success': False, 'error': '未选择文件'}), 400
|
||||
|
||||
model_name = request.form.get('model', 'base')
|
||||
language = request.form.get('language', 'auto')
|
||||
device = request.form.get('device', 'cpu')
|
||||
output_format = request.form.get('format', 'text')
|
||||
|
||||
if model_name not in _scan_models(_load_config()['model_dir']):
|
||||
return jsonify({'success': False, 'error': f'模型 {model_name} 不存在,请先下载到 stt_models 目录'}), 400
|
||||
|
||||
# 保存原始文件
|
||||
ext = os.path.splitext(audio_file.filename)[1].lower()
|
||||
original_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + ext)
|
||||
audio_file.save(original_path)
|
||||
|
||||
# 转 WAV
|
||||
wav_path = _convert_to_wav(original_path)
|
||||
try:
|
||||
os.remove(original_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not wav_path:
|
||||
return jsonify({'success': False, 'error': '音频转换失败,请确保 FFmpeg 已安装'}), 500
|
||||
|
||||
# 创建任务
|
||||
task_id = uuid.uuid4().hex
|
||||
_task_progress[task_id] = {'percent': 0, 'status': 'queued', 'format': output_format}
|
||||
_task_queue.append({
|
||||
'task_id': task_id,
|
||||
'wav_path': wav_path,
|
||||
'model': model_name,
|
||||
'language': language,
|
||||
'device': device,
|
||||
'format': output_format,
|
||||
})
|
||||
|
||||
_ensure_worker()
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
|
||||
|
||||
@bp.route('/status/<task_id>')
|
||||
def task_status(task_id):
|
||||
progress = _task_progress.get(task_id)
|
||||
if not progress:
|
||||
return jsonify({'success': False, 'error': '任务不存在'}), 404
|
||||
|
||||
resp = {'success': True, 'progress': progress}
|
||||
|
||||
if progress['status'] == 'done':
|
||||
results = _task_results.get(task_id, [])
|
||||
fmt = progress.get('format', 'text')
|
||||
|
||||
if fmt == 'srt':
|
||||
lines = []
|
||||
for i, r in enumerate(results):
|
||||
lines.append(f"{i+1}\n{r['start_time']} --> {r['end_time']}\n{r['text']}\n")
|
||||
resp['result'] = '\n'.join(lines)
|
||||
elif fmt == 'json':
|
||||
resp['result'] = results
|
||||
else:
|
||||
resp['result'] = '\n'.join(r['text'] for r in results)
|
||||
|
||||
# 清理
|
||||
_task_progress.pop(task_id, None)
|
||||
_task_results.pop(task_id, None)
|
||||
|
||||
return jsonify(resp)
|
||||
9
flask-dev-api/blueprints/token_gen.py
Normal file
9
flask-dev-api/blueprints/token_gen.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint('token_gen', __name__, url_prefix='/token-gen')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('token_gen.html')
|
||||
9
flask-dev-api/blueprints/url_parser.py
Normal file
9
flask-dev-api/blueprints/url_parser.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint('url_parser', __name__, url_prefix='/url-parser')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def page():
|
||||
return render_template('url_parser.html')
|
||||
Reference in New Issue
Block a user