--- a/web_review/server.py +++ b/web_review/server.py @@ -9,7 +9,7 @@ import subprocess from pathlib import Path from typing import Optional, Dict, Any, List -from pydantic import BaseModel +from pydantic import BaseModel, model_validator import uvicorn from fastapi import FastAPI, BackgroundTasks, HTTPException, Query @@ -17,7 +17,12 @@ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware +# Module-level Port & Host Configuration +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 1515 + BASE_DIR = Path(__file__).resolve().parent.parent +PIPELINE_DIR = BASE_DIR / "05_Production_Pipeline" VIDEOS_DIR = BASE_DIR / "04_Assets" / "videos" EXPORTS_DIR = BASE_DIR / "06_Exports" CHARACTERS_DIR = BASE_DIR / "04_Assets" / "characters" @@ -27,6 +32,19 @@ TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" DOWNLOADS_DIR = Path(os.path.expanduser("~")) / "Downloads" +# Ensure pipeline directory is on sys.path for direct imports +for p in [str(PIPELINE_DIR), str(BASE_DIR)]: + if p not in sys.path: + sys.path.insert(0, p) + +# Reconfigure stdout/stderr to UTF-8 on Windows to prevent cp1252 UnicodeEncodeError +if sys.platform == "win32": + try: + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + except Exception: + pass + VIDEOS_DIR.mkdir(parents=True, exist_ok=True) EXPORTS_DIR.mkdir(parents=True, exist_ok=True) @@ -50,7 +68,7 @@ # Global generation state gen_state = { - "status": "idle", # idle, generating, completed, error + "status": "idle", # idle, generating, concatenating, completed, error "progress_text": "", "current_prompt": "", "last_video": "thap_ngu_nien_ep01_to_ep06_grand_feature_master_1060s_17m40s.mp4", @@ -63,12 +81,32 @@ entry = f"[{ts}] {msg}" gen_state["logs"].append(entry) gen_state["logs"] = gen_state["logs"][-30:] # keep last 30 - print(entry) + try: + print(entry) + except Exception: + try: + print(entry.encode("ascii", errors="replace").decode("ascii")) + except Exception: + pass class GenerateRequest(BaseModel): - prompt: str + prompt: Optional[str] = None + shot_id: Optional[str] = None scene_id: Optional[str] = None title: Optional[str] = None + input_frame: Optional[str] = None + force: Optional[bool] = False + + @model_validator(mode="after") + def check_prompt_or_shot(self): + if not self.prompt and not self.shot_id: + raise ValueError("Either prompt or shot_id must be provided") + return self + +class ConcatRequest(BaseModel): + scene_id: str + output_path: Optional[str] = None + crossfade_dur: Optional[float] = 1.0 EPISODE_TITLES = { 1: "Tập 1: Phong Trần Nhập Cuộc (Gia Biến)", @@ -82,6 +120,16 @@ # Cache for library scan _library_cache: Dict[str, Any] = {} _cache_time: float = 0.0 + +def resolve_next_version(base_dir: Path, stem: str, suffix: str = ".mp4") -> str: + """Tự động xác định tên file version tiếp theo (_v1, _v2, _v3...).""" + pattern = re.compile(rf"^{re.escape(stem)}_v(\d+){re.escape(suffix)}$", re.IGNORECASE) + highest_v = 0 + for f in base_dir.glob(f"{stem}*{suffix}"): + m = pattern.match(f.name) + if m: + highest_v = max(highest_v, int(m.group(1))) + return f"{stem}_v{highest_v + 1}{suffix}" def build_library_data() -> Dict[str, Any]: global _library_cache, _cache_time @@ -110,6 +158,8 @@ mtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime)) url = f"/assets/exports/{rel_to_exports}" abs_path = str(p.resolve()) + v_m = re.search(r"_v(\d+)\.mp4$", lower) + version_num = int(v_m.group(1)) if v_m else 1 # Shorts 9:16 if "9x16" in lower or "shorts_1m_9x16" in rel_to_exports: @@ -140,7 +190,8 @@ "aspect": "9:16", "duration_str": "60 Giây (9:16)", "category": "short_9x16", - "badge": f"Shorts Tập {ep_num}" + "badge": f"Shorts Tập {ep_num}", + "version": version_num }) continue @@ -193,7 +244,8 @@ "duration_str": dur_str or "Bản Dài", "badge": badge, "priority": priority, - "category": "feature_master" + "category": "feature_master", + "version": version_num }) continue @@ -228,12 +280,13 @@ "duration_str": dur_str or f"{size_mb} MB", "badge": badge, "priority": priority, - "category": "continuous_cut" + "category": "continuous_cut", + "version": version_num }) continue - # Episode Grand Master - if "grand_feature_master" in lower: + # Episode Grand Master (hỗ trợ cả grand_feature_master và grand_master_v*) + if "grand_feature_master" in lower or "grand_master" in lower: ep_m = re.search(r"ep0?(\d+)", lower) ep_num = int(ep_m.group(1)) if ep_m else 1 dur_m = re.search(r"(\d+min|\d+m\d+s|\d+s)", lower) @@ -255,7 +308,8 @@ "aspect": "16:9", "duration_str": dur_str or f"{size_mb} MB", "badge": f"Master Tập {ep_num}", - "category": "episode_master" + "category": "episode_master", + "version": version_num }) continue @@ -279,7 +333,8 @@ "modified": mtime, "aspect": "16:9", "badge": f"Tập {ep_num} • Cảnh {sc_num:02d}", - "category": "scene_master" + "category": "scene_master", + "version": version_num }) continue @@ -299,6 +354,8 @@ mtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime)) url = f"/assets/videos/{name}" abs_path = str(p.resolve()) + v_m = re.search(r"_v(\d+)\.mp4$", lower) + version_num = int(v_m.group(1)) if v_m else 1 ep_m = re.search(r"ep0?(\d+)", lower) sc_m = re.search(r"scene0?(\d+)", lower) @@ -313,7 +370,8 @@ raw_shots_by_scene[scene_key] = [] is_shot = "shot" in lower - item_title = f"Tập {ep_num} • Cảnh {sc_num:02d} • Shot {sh_num:02d} (10s)" if is_shot else name.replace(".mp4", "").replace("_", " ") + v_tag = f" v{version_num}" if v_m else "" + item_title = f"Tập {ep_num} • Cảnh {sc_num:02d} • Shot {sh_num:02d}{v_tag} (10s)" if is_shot else name.replace(".mp4", "").replace("_", " ") item = { "name": name, @@ -330,13 +388,14 @@ "modified": mtime, "duration_str": "10s" if is_shot else "Raw/Cut", "aspect": "16:9", - "category": "raw_shot" if is_shot else "raw_asset" + "category": "raw_shot" if is_shot else "raw_asset", + "version": version_num } raw_shots_by_scene[scene_key].append(item) raw_files_list.append(item) for sc in raw_shots_by_scene: - raw_shots_by_scene[sc].sort(key=lambda x: (x["shot_num"], x["name"])) + raw_shots_by_scene[sc].sort(key=lambda x: (x["shot_num"], x["version"], x["name"])) # Sort scene groups sorted_raw_scenes = [] @@ -441,19 +500,37 @@ return {} @app.get("/api/shots") -async def list_shots_api(): +async def list_shots_api( + scene: Optional[str] = Query(None, description="Lọc theo Scene ID (vd: ep01_scene02)"), + episode: Optional[str] = Query(None, description="Lọc theo Episode ID (vd: ep01)") +): + shots = {} if PROMPTS_FILE.exists(): try: data = json.loads(PROMPTS_FILE.read_text(encoding="utf-8")) - return {"shots": data.get("motion_prompts", {})} + shots = data.get("motion_prompts", {}) except Exception as e: return {"error": str(e), "shots": {}} - return {"shots": {}} + + if scene: + sc_clean = scene.lower().strip() + shots = {k: v for k, v in shots.items() if sc_clean in k.lower()} + elif episode: + ep_clean = episode.lower().strip() + shots = {k: v for k, v in shots.items() if k.lower().startswith(ep_clean)} + + return {"shots": shots} @app.get("/api/scenes") -async def list_scenes_api(): +async def list_scenes_api( + episode: Optional[str] = Query(None, description="Lọc theo Episode ID (vd: ep01)") +): lib = build_library_data() - return {"scenes": lib.get("raw_scenes", [])} + raw_scenes = lib.get("raw_scenes", []) + if episode: + ep_clean = episode.lower().strip() + raw_scenes = [sc for sc in raw_scenes if sc.get("scene_key", "").startswith(ep_clean)] + return {"scenes": raw_scenes} @app.get("/api/episodes") async def list_episodes(): @@ -480,7 +557,7 @@ return gen_state def run_agent_browser_generation(prompt: str, scene_title: str): - global gen_state + global gen_state, _library_cache try: gen_state["status"] = "generating" gen_state["current_prompt"] = prompt @@ -528,20 +605,34 @@ if new_files: target_source = list(new_files)[0] - clean_name = f"muse_video_{int(time.time())}.mp4" + safe_stem = "custom_generation" if scene_title: - safe_title = "".join(c if c.isalnum() else "_" for c in scene_title).strip("_").lower() - clean_name = f"{safe_title}_{int(time.time())}.mp4" + safe_stem = "".join(c if c.isalnum() else "_" for c in scene_title).strip("_").lower() + clean_name = resolve_next_version(VIDEOS_DIR, safe_stem) target_dest = VIDEOS_DIR / clean_name shutil.copy2(target_source, target_dest) + + # Extract tail frame if OpenCV is available + try: + import cv2 + cap = cv2.VideoCapture(str(target_dest)) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, total_frames - 1)) + ret, tail_frame = cap.read() + cap.release() + if ret and tail_frame is not None: + kf_dir = KEYFRAMES_DIR / safe_stem + kf_dir.mkdir(parents=True, exist_ok=True) + cv2.imwrite(str(kf_dir / "clean_frame_239.jpg"), tail_frame) + except Exception: + pass + gen_state["last_video"] = clean_name gen_state["status"] = "completed" gen_state["progress_text"] = f"Đã tải thành công: {clean_name}" log_msg(f"Hoàn thành: Đã lưu {clean_name}") video_downloaded = True - # Invalidate cache - global _library_cache _library_cache = {} break @@ -555,14 +646,100 @@ gen_state["progress_text"] = f"Lỗi: {str(e)}" log_msg(f"Exception: {str(e)}") +def run_unified_generation(req: GenerateRequest): + """Điều phối sinh video: ưu tiên orchestrator nếu có shot_id, fallback prompt nếu tùy chỉnh.""" + global gen_state, _library_cache + shot_id = req.shot_id + prompt = req.prompt + scene_id = req.scene_id + title = req.title or scene_id or (shot_id if shot_id else "Custom") + + if shot_id: + try: + from production_orchestrator import render_single_shot, find_rendered_video + gen_state["status"] = "generating" + gen_state["current_prompt"] = prompt or f"[Shot: {shot_id}]" + gen_state["progress_text"] = f"Đang render shot {shot_id} qua Production Orchestrator..." + log_msg(f"Bắt đầu render shot: {shot_id}") + + success = render_single_shot( + shot_id, + input_frame=req.input_frame, + prompt_override=prompt + ) + if success: + v_path = find_rendered_video(shot_id) + v_name = v_path.name if v_path else f"{shot_id}_10s.mp4" + gen_state["status"] = "completed" + gen_state["last_video"] = v_name + gen_state["progress_text"] = f"Đã render thành công: {v_name}" + log_msg(f"Hoàn thành shot {shot_id}: {v_name}") + _library_cache = {} + return + else: + gen_state["status"] = "error" + gen_state["progress_text"] = f"Lỗi render shot {shot_id}" + log_msg(f"Lỗi render shot {shot_id}") + return + except Exception as e: + gen_state["status"] = "error" + gen_state["progress_text"] = f"Lỗi: {str(e)}" + log_msg(f"Exception render shot {shot_id}: {str(e)}") + return + + if prompt: + run_agent_browser_generation(prompt, title) + +def run_scene_concat(scene_id: str, output_path: Optional[str] = None, crossfade_dur: float = 1.0): + """Thực thi ghép nối Master Scene qua Production Orchestrator.""" + global gen_state, _library_cache + try: + gen_state["status"] = "concatenating" + gen_state["progress_text"] = f"Đang ghép nối các shot cho cảnh {scene_id}..." + log_msg(f"Bắt đầu ghép nối Master cảnh: {scene_id}") + + try: + from production_orchestrator import concat_scene_shots + result = concat_scene_shots(scene_id, output_path=output_path, crossfade_dur=crossfade_dur) + except ImportError: + result = None + log_msg("Lỗi: Không tìm thấy module production_orchestrator") + + if result and Path(result).exists(): + gen_state["status"] = "completed" + gen_state["last_video"] = Path(result).name + gen_state["progress_text"] = f"Ghép nối thành công cảnh {scene_id}: {Path(result).name}" + log_msg(f"Hoàn thành ghép nối cảnh {scene_id} -> {Path(result).name}") + _library_cache = {} + else: + gen_state["status"] = "error" + gen_state["progress_text"] = f"Ghép nối cảnh {scene_id} thất bại (thiếu shot hoặc lỗi FFmpeg)" + log_msg(f"Thất bại khi ghép nối cảnh: {scene_id}") + except Exception as e: + gen_state["status"] = "error" + gen_state["progress_text"] = f"Lỗi ghép nối: {str(e)}" + log_msg(f"Exception khi ghép nối {scene_id}: {str(e)}") + @app.post("/api/generate") async def trigger_generate(req: GenerateRequest, bg_tasks: BackgroundTasks): global gen_state - if gen_state["status"] == "generating": - return JSONResponse({"status": "busy", "message": "Một tác vụ sinh video đang chạy"}, status_code=409) + if gen_state["status"] in ("generating", "concatenating"): + return JSONResponse({"status": "busy", "message": "Một tác vụ sinh video hoặc ghép nối đang chạy"}, status_code=409) - bg_tasks.add_task(run_agent_browser_generation, req.prompt, req.title or req.scene_id or "") + bg_tasks.add_task(run_unified_generation, req) return {"status": "accepted", "message": "Đã bắt đầu tác vụ sinh video trong nền"} +@app.post("/api/concat") +async def trigger_concat(req: ConcatRequest, bg_tasks: BackgroundTasks): + global gen_state + if gen_state["status"] in ("generating", "concatenating"): + return JSONResponse({"status": "busy", "message": "Một tác vụ khác đang chạy"}, status_code=409) + + if not req.scene_id or not req.scene_id.strip(): + raise HTTPException(status_code=422, detail="scene_id is required") + + bg_tasks.add_task(run_scene_concat, req.scene_id.strip(), req.output_path, req.crossfade_dur or 1.0) + return {"status": "accepted", "message": f"Đã bắt đầu tác vụ ghép nối cảnh {req.scene_id} trong nền"} + if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=1515, log_level="info") + uvicorn.run(app, host=DEFAULT_HOST, port=DEFAULT_PORT, log_level="info")