mcp-demo/web.py.current
GoMrStone 1a7567231a feat: 录音素材管理系统 - 完整功能
- 视频分析: MiMo 2.5 大模型直接分析
- 场景检测: AI 视觉模型识别分镜头
- 语音识别: 每个场景单独 ASR
- 语音克隆: MiMo TTS 语音克隆
- 下载功能: 单文件/批量 ZIP 下载
- 系统设置: API 密钥配置
- 文件夹管理: 创建/移动/筛选
- 扁平化 UI: 简约按钮样式
2026-07-10 17:26:53 +08:00

710 lines
24 KiB
Plaintext

"""
Footage Screener 鈥?Web UI (Database-backed)
"""
import json
import os
import sys
import threading
import time
from pathlib import Path
from werkzeug.utils import secure_filename
from flask import Flask, render_template, jsonify, send_file, request, Response
from db import (
init_db, insert_footage, list_footage, get_footage_by_id, get_stats,
upsert_screener, delete_footage as db_delete_footage,
soft_delete_footage, restore_footage, permanent_delete_footage,
set_clip_status, create_task, update_task, complete_task,
list_tasks, get_running_task, migrate_from_json,
get_db, list_all_categories, create_category as db_create_category,
move_footage_to_category,
)
from analyzer import analyze_video as video_analyze
sys.stdout.reconfigure(encoding="utf-8")
app = Flask(__name__,
template_folder=os.path.join(os.path.dirname(__file__), "templates"),
static_folder=os.path.join(os.path.dirname(__file__), "static"))
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 3600 # Cache static files 1 hour
app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024 * 1024 # 4GB max upload
# Unified video storage directory
VIDEO_STORAGE_DIR = os.environ.get("VIDEO_STORAGE_DIR") or os.path.join(os.path.dirname(__file__), "videos")
UPLOAD_DIR = os.path.join(VIDEO_STORAGE_DIR, "uploads")
THUMB_DIR = os.path.join(os.path.dirname(__file__), "static", "thumbs")
ALLOWED_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".mts", ".mxf", ".wmv", ".flv", ".mpg", ".mpeg", ".3gp", ".webm", ".ts", ".m4v"}
# Global state
_analysis_running = False
_analysis_progress = {"current": 0, "total": 0, "file": "", "status": "idle"}
# === Routes ===
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/footage")
def api_footage():
category = request.args.get("category", "")
search = request.args.get("search", "")
ai_search = request.args.get("ai_search", "")
min_dur = request.args.get("min_duration", type=float, default=0)
max_dur = request.args.get("max_duration", type=float, default=99999)
screener_filter = request.args.get("screener", "")
page = request.args.get("page", type=int, default=1)
per_page = request.args.get("per_page", type=int, default=60)
items, total = list_footage(
category=category, search=search, ai_search=ai_search,
min_dur=min_dur, max_dur=max_dur,
screener_filter=screener_filter,
page=page, per_page=per_page,
status=request.args.get("status", "active"),
)
return jsonify({
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": max(1, (total + per_page - 1) // per_page),
})
@app.route("/api/stats")
def api_stats():
return jsonify(get_stats())
@app.route("/video/<int:clip_id>")
def serve_video(clip_id):
"""Stream video file with range request support."""
clip = get_footage_by_id(clip_id)
if not clip:
return "Clip not found", 404
full_path = clip.get("filepath", "")
if not os.path.exists(full_path):
return "File not found", 404
ext = clip["filename"].lower().rsplit(".", 1)[-1]
mime_map = {"mp4": "video/mp4", "mov": "video/quicktime", "avi": "video/x-msvideo",
"mkv": "video/x-matroska", "mts": "video/mp2t"}
mime = mime_map.get(ext, "video/mp4")
file_size = os.path.getsize(full_path)
range_header = request.headers.get("Range")
if range_header:
byte_start = int(range_header.replace("bytes=", "").split("-")[0])
byte_end = min(byte_start + 5 * 1024 * 1024, file_size - 1)
length = byte_end - byte_start + 1
def generate_range():
with open(full_path, "rb") as f:
f.seek(byte_start)
remaining = length
while remaining > 0:
chunk = f.read(min(65536, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
return Response(
generate_range(), status=206, mimetype=mime,
headers={
"Content-Range": f"bytes {byte_start}-{byte_end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(length),
},
)
return send_file(full_path, mimetype=mime)
# === Analysis API ===
@app.route("/api/analyze", methods=["POST"])
def api_analyze():
global _analysis_running
if _analysis_running:
return jsonify({"error": "Analysis already running"}), 409
data = request.json or {}
mode = data.get("mode", "all")
selected_ids = set(data.get("selected_ids", []))
cv_only = data.get("cv_only", False)
# Get targets from DB
if mode == "selected" and selected_ids:
targets = [get_footage_by_id(i) for i in selected_ids]
targets = [t for t in targets if t]
elif mode == "unanalyzed":
items, _ = list_footage(screener_filter="未分析", per_page=9999)
targets = items
else:
items, _ = list_footage(per_page=9999)
targets = items
if not targets:
return jsonify({"error": "No videos to analyze"}), 400
_analysis_running = True
task_id = create_task(mode, len(targets))
thread = threading.Thread(target=_run_analysis, args=(targets, cv_only, task_id), daemon=True)
thread.start()
return jsonify({"message": f"Analyzing {len(targets)} videos...", "total": len(targets), "task_id": task_id})
def _analyze_single(clip: dict, cv_only: bool, task_id: int, progress: dict) -> bool:
"""Analyze a single video using MiMo 2.5."""
filepath = clip.get("filepath", "")
footage_id = clip.get("id", 0)
if not filepath or not os.path.exists(filepath) or not footage_id:
return False
try:
# Use new AI-only analyzer
result = video_analyze(filepath)
if result.error:
print(f"Analysis error for {clip.get('filename')}: {result.error}")
# Calculate decision score
final_score = result.final_score
# Determine decision based on quality
if result.quality_score >= 7:
decision = "KEEP"
elif result.quality_score >= 4:
decision = "REVIEW"
else:
decision = "DELETE"
decision_reason = result.decision_reason or result.quality_reason
upsert_screener(footage_id, {
"cv_score": 0,
"ai_score": round(final_score, 1),
"final_score": round(final_score, 1),
"decision": decision,
"decision_reason": decision_reason,
"blur_score": 0,
"shake_score": 0,
"black_frame_ratio": 0,
"overexposed_ratio": 0,
"underexposed_ratio": 0,
"is_silent": 0,
"ai_valid": 1 if decision == "KEEP" else 0,
"ai_description": result.description,
"ai_quality": result.quality_score,
"ai_reason": result.quality_reason,
"ai_mode": result.analysis_mode,
"issues": [],
"transcription": result.transcription,
"scenes": result.shots,
})
return True
except Exception as e:
print(f"Error analyzing {clip.get('filename')}: {e}")
return False
def _safe_scene_analysis(filepath: str) -> dict:
"""Scene analysis with error protection."""
try:
from scene_analyzer import analyze_scenes
return analyze_scenes(filepath)
except Exception as e:
print(f"Scene analysis error: {e}")
return {"transcription": "", "scenes": [], "summary": str(e)}
def _run_analysis(targets: list[dict], cv_only: bool = False, task_id: int = 0):
global _analysis_running, _analysis_progress
_analysis_progress = {"current": 0, "total": len(targets), "file": "", "status": "running"}
error_msg = ""
import concurrent.futures
# Process videos in batches of 3 for parallel execution
batch_size = 3
for batch_start in range(0, len(targets), batch_size):
batch = targets[batch_start:batch_start + batch_size]
with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as executor:
futures = {}
for clip in batch:
future = executor.submit(_analyze_single, clip, cv_only, task_id, _analysis_progress)
futures[future] = clip
for future in concurrent.futures.as_completed(futures):
clip = futures[future]
try:
success = future.result(timeout=600)
_analysis_progress["current"] += 1
_analysis_progress["file"] = clip.get("filename", "")
if task_id:
update_task(task_id, completed_count=_analysis_progress["current"],
current_file=clip.get("filename", ""))
except Exception as e:
error_msg = str(e)
_analysis_progress["current"] += 1 # count errors too
print(f"Error analyzing {clip.get('filename')}: {e}")
if task_id:
update_task(task_id, completed_count=_analysis_progress["current"],
current_file=clip.get("filename", ""))
if task_id:
complete_task(task_id, error_msg)
_analysis_progress["status"] = "done"
_analysis_running = False
@app.route("/api/clip", methods=["POST"])
def api_clip():
"""Clip a segment from a video."""
data = request.json or {}
footage_id = data.get("id")
start = data.get("start", 0)
end = data.get("end", 0)
filename = data.get("filename", "clip")
if not footage_id or end <= start:
return jsonify({"error": "Invalid parameters"}), 400
clip = get_footage_by_id(footage_id)
if not clip:
return jsonify({"error": "Clip not found"}), 404
filepath = clip.get("filepath", "")
if not os.path.exists(filepath):
return jsonify({"error": "File not found"}), 404
# Create output filename
clip_dir = os.path.join(os.path.dirname(__file__), "clips")
os.makedirs(clip_dir, exist_ok=True)
stem = Path(filename).stem
ext = Path(filename).suffix
output_name = f"{stem}_clip_{int(start)}-{int(end)}{ext}"
output_path = os.path.join(clip_dir, output_name)
# FFmpeg clip
cmd = [
"ffmpeg", "-y", "-v", "quiet",
"-ss", str(start),
"-i", filepath,
"-t", str(end - start),
"-c", "copy",
output_path,
]
try:
proc = subprocess.run(cmd, capture_output=True, timeout=60,
encoding="utf-8", errors="replace")
if proc.returncode == 0 and os.path.exists(output_path):
return jsonify({"ok": True, "output": output_name, "path": output_path})
else:
return jsonify({"error": "FFmpeg failed"}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/clip-status", methods=["POST"])
def api_clip_status():
"""Mark footage as clipped or unclipped."""
data = request.json or {}
footage_id = data.get("id")
status = data.get("status", "clipped") # "clipped" or ""
if not footage_id:
return jsonify({"error": "No ID"}), 400
set_clip_status(footage_id, status)
return jsonify({"ok": True})
@app.route("/api/analyze-scenes", methods=["POST"])
def api_analyze_scenes():
"""Analyze all scenes/shots in a video."""
data = request.json or {}
footage_id = data.get("id")
if not footage_id:
return jsonify({"error": "No footage ID"}), 400
clip = get_footage_by_id(footage_id)
if not clip:
return jsonify({"error": "Clip not found"}), 404
filepath = clip.get("filepath", "")
if not os.path.exists(filepath):
return jsonify({"error": "File not found"}), 404
from scene_analyzer import analyze_scenes
result = analyze_scenes(filepath)
return jsonify(result)
@app.route("/api/tasks")
def api_tasks():
"""List analysis tasks."""
tasks = list_tasks(limit=20)
running = get_running_task()
return jsonify({"tasks": tasks, "running": running})
@app.route("/api/analyze/status")
def api_analyze_status():
return jsonify(_analysis_progress)
@app.route("/api/scan", methods=["POST"])
def api_scan():
data = request.json or {}
base_dir = data.get("directory", "")
if not base_dir or not os.path.exists(base_dir):
return jsonify({"error": "Invalid directory"}), 400
from scanner import scan_directory
footage = scan_directory(base_dir)
return jsonify({"message": f"Scanned {len(footage)} clips", "count": len(footage)})
@app.route("/api/batch/delete", methods=["POST"])
def api_batch_delete():
data = request.json or {}
confirm = data.get("confirm", False)
ids = data.get("ids", [])
if ids:
# Soft delete specific items by ID (move to recycle bin)
if not confirm:
items = [get_footage_by_id(i) for i in ids]
items = [i for i in items if i]
return jsonify({"count": len(items), "files": [f["filename"] for f in items]})
moved = []
for fid in ids:
clip = get_footage_by_id(fid)
if not clip:
continue
soft_delete_footage(fid)
moved.append(clip["filename"])
return jsonify({"deleted": len(moved), "files": moved})
else:
# Soft delete all DELETE-category items
items, _ = list_footage(screener_filter="DELETE", per_page=9999)
if not confirm:
return jsonify({"count": len(items), "files": [f["filename"] for f in items]})
moved = []
for f in items:
soft_delete_footage(f["id"])
moved.append(f["filename"])
return jsonify({"deleted": len(moved), "files": moved})
@app.route("/api/restore", methods=["POST"])
def api_restore():
"""Restore items from recycle bin."""
data = request.json or {}
ids = data.get("ids", [])
if not ids:
return jsonify({"error": "No IDs provided"}), 400
restored = []
for fid in ids:
clip = get_footage_by_id(fid)
if clip:
restore_footage(fid)
restored.append(clip["filename"])
return jsonify({"restored": len(restored), "files": restored})
@app.route("/api/permanent-delete", methods=["POST"])
def api_permanent_delete():
"""Permanently delete items from recycle bin."""
data = request.json or {}
ids = data.get("ids", [])
if not ids:
return jsonify({"error": "No IDs provided"}), 400
deleted = []
for fid in ids:
filepath = permanent_delete_footage(fid)
if filepath and os.path.exists(filepath):
try:
os.remove(filepath)
except Exception:
pass
clip = get_footage_by_id(fid)
if clip:
deleted.append(clip["filename"])
return jsonify({"deleted": len(deleted), "files": deleted})
@app.route("/api/export")
def api_export():
selected_ids = request.args.get("ids", "")
if selected_ids:
ids = [int(x) for x in selected_ids.split(",") if x.strip()]
items = [get_footage_by_id(i) for i in ids]
items = [i for i in items if i]
else:
items, _ = list_footage(per_page=9999)
report = {
"total": len(items),
"keep": len([f for f in items if f.get("screener", {}).get("decision") == "KEEP"]),
"review": len([f for f in items if f.get("screener", {}).get("decision") == "REVIEW"]),
"delete": len([f for f in items if f.get("screener", {}).get("decision") == "DELETE"]),
"clips": items,
}
return jsonify(report)
# === Upload API ===
@app.route("/api/upload", methods=["POST"])
def api_upload():
"""Upload video files. Saves to uploads/ dir, extracts metadata, writes to DB."""
if "files" not in request.files:
return jsonify({"error": "No files provided"}), 400
files = request.files.getlist("files")
category = request.form.get("category", "涓婁紶绱犳潗")
os.makedirs(UPLOAD_DIR, exist_ok=True)
from scanner import generate_thumbnail
uploaded = []
errors = []
for f in files:
if not f.filename:
continue
ext = os.path.splitext(f.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
errors.append({"filename": f.filename, "error": f"Unsupported format: {ext}"})
continue
# Save file (handle duplicates)
filename = secure_filename(f.filename)
if not filename:
filename = f"upload_{int(time.time())}{ext}"
dest_path = os.path.join(UPLOAD_DIR, filename)
if os.path.exists(dest_path):
name, ext2 = os.path.splitext(filename)
filename = f"{name}_{int(time.time())}{ext2}"
dest_path = os.path.join(UPLOAD_DIR, filename)
try:
f.save(dest_path)
except Exception as e:
errors.append({"filename": f.filename, "error": str(e)})
continue
# Extract metadata
from cv_analyzer import _get_video_info
info = _get_video_info(dest_path)
# Generate thumbnail
thumb_name = f"{category}_{filename}.jpg"
thumb_path = os.path.join(THUMB_DIR, thumb_name)
thumb_rel = f"thumbs/{thumb_name}"
generate_thumbnail(dest_path, thumb_path)
# Insert to DB
clip = {
"filename": filename,
"filepath": dest_path,
"category": category,
"thumbnail": thumb_rel,
"duration": round(info["duration"], 2),
"width": info["width"],
"height": info["height"],
"fps": round(info["fps"], 2),
"total_frames": info["total_frames"],
"size_mb": round(os.path.getsize(dest_path) / 1024 / 1024, 1),
}
clip_id = insert_footage(clip)
clip["id"] = clip_id
uploaded.append(clip)
return jsonify({
"uploaded": len(uploaded),
"errors": len(errors),
"files": uploaded,
"error_details": errors,
})
@app.route("/api/upload/status")
def api_upload_status():
"""Check upload directory."""
if not os.path.exists(UPLOAD_DIR):
return jsonify({"count": 0, "total_size_mb": 0})
files = [f for f in os.listdir(UPLOAD_DIR) if os.path.isfile(os.path.join(UPLOAD_DIR, f))]
total_size = sum(os.path.getsize(os.path.join(UPLOAD_DIR, f)) for f in files)
return jsonify({"count": len(files), "total_size_mb": round(total_size / 1024 / 1024, 1)})
@app.route("/api/scene-notes/batch", methods=["POST"])
def update_scene_notes_batch():
"""Update notes for multiple scenes at once."""
data = request.json or {}
footage_id = data.get("footage_id")
updates = data.get("updates", [])
if not footage_id or not updates:
return jsonify({"error": "footage_id and updates required"}), 400
try:
with get_db() as conn:
# Get current scenes
row = conn.execute("SELECT scenes FROM screener WHERE footage_id = ?", (footage_id,)).fetchone()
if not row:
return jsonify({"error": "No analysis found for this footage"}), 404
scenes = json.loads(row["scenes"]) if row["scenes"] else []
# Apply updates
for update in updates:
scene_index = update.get("scene_index")
notes = update.get("notes", "")
for scene in scenes:
if scene.get("scene_index") == scene_index or scene.get("shot_index") == scene_index:
scene["notes"] = notes
break
# Save back to database
conn.execute("UPDATE screener SET scenes = ? WHERE footage_id = ?",
(json.dumps(scenes, ensure_ascii=False), footage_id))
conn.commit()
return jsonify({"success": True, "updated": len(updates)})
except Exception as e:
return jsonify({"error": str(e)}), 500
# === Scene Notes API ===
@app.route("/api/scene-notes", methods=["POST"])
def update_scene_notes():
data = request.json or {}
footage_id = data.get("footage_id")
scene_index = data.get("scene_index")
notes = data.get("notes", "")
if not footage_id or scene_index is None:
return jsonify({"error": "footage_id and scene_index required"}), 400
try:
with get_db() as conn:
# Get current scenes
row = conn.execute("SELECT scenes FROM screener WHERE footage_id = ?", (footage_id,)).fetchone()
if not row:
return jsonify({"error": "No analysis found for this footage"}), 404
scenes = json.loads(row["scenes"]) if row["scenes"] else []
# Find and update the scene
updated = False
for scene in scenes:
if scene.get("scene_index") == scene_index or scene.get("shot_index") == scene_index:
scene["notes"] = notes
updated = True
break
if not updated:
return jsonify({"error": "Scene not found"}), 404
# Save back to database
conn.execute("UPDATE screener SET scenes = ? WHERE footage_id = ?",
(json.dumps(scenes, ensure_ascii=False), footage_id))
conn.commit()
return jsonify({"success": True, "notes": notes})
except Exception as e:
return jsonify({"error": str(e)}), 500
# === Category Management API ===
@app.route("/api/categories", methods=["GET"])
def list_categories():
"""获取所有分类列表"""
categories = list_all_categories()
return jsonify({"categories": categories})
@app.route("/api/category/create", methods=["POST"])
def create_category():
"""创建新分类(文件夹)"""
data = request.json or {}
name = data.get("name", "").strip()
if not name:
return jsonify({"error": "分类名称不能为空"}), 400
if len(name) > 50:
return jsonify({"error": "分类名称不能超过50个字符"}), 400
if db_create_category(name):
return jsonify({"success": True, "category": name})
else:
return jsonify({"error": "分类已存在"}), 400
@app.route("/api/category/move", methods=["POST"])
def move_to_category():
"""将选中的视频移动到指定分类"""
data = request.json or {}
ids = data.get("ids", [])
category = data.get("category", "").strip()
if not ids:
return jsonify({"error": "请选择要移动的视频"}), 400
if not category:
return jsonify({"error": "请选择目标分类"}), 400
moved = move_footage_to_category(ids, category)
return jsonify({"success": True, "moved": moved})
if __name__ == "__main__":
# Auto-migrate from JSON if DB is empty
init_db()
json_cache = os.path.join(os.path.dirname(__file__), "data", "footage_cache.json")
stats = get_stats()
if stats["categories"]["全部"]["count"] == 0 and os.path.exists(json_cache):
print("Migrating from JSON cache...")
count = migrate_from_json(json_cache)
print(f"Migrated {count} items.")
# Fix any stuck running tasks from previous session
from db import get_db as _get_db
with _get_db() as _conn:
stuck = _conn.execute("SELECT COUNT(*) FROM tasks WHERE status='running'").fetchone()[0]
if stuck > 0:
_conn.execute("UPDATE tasks SET status='completed', completed_at=CURRENT_TIMESTAMP WHERE status='running'")
print(f"Fixed {stuck} stuck task(s)")
port = int(os.environ.get("PORT", 5000))
host = os.environ.get("HOST", "0.0.0.0") # 0.0.0.0 = 灞€鍩熺綉鍙闂? print(f"\n 馃幀 Footage Screener Web UI")
print(f" 鏈満璁块棶: http://localhost:{port}")
print(f" 灞€鍩熺綉璁块棶: http://<浣犵殑IP>:{port}")
print(f" 鎸?Ctrl+C 鍋滄鏈嶅姟\n")
app.run(host=host, port=port, debug=False)