""" turntable_cache.py — persistent state for Qwen turntable generation. State stored as JSON: {output_dir}/_turntable/{group_id}/state.json Views stored alongside: {output_dir}/_turntable/{group_id}/views/view_NNN_DDDdeg.png Final video: {output_dir}/_turntable/{group_id}/turntable.mp4 One state file per group tracks completed angles so background generation can resume after restart without re-rendering anything that's already on disk. """ import os import json import time from typing import Optional _HERE = os.path.dirname(os.path.abspath(__file__)) def cache_dir(output_dir: str, group_id: str) -> str: return os.path.join(output_dir, "_turntable", str(group_id)) def state_path(output_dir: str, group_id: str) -> str: return os.path.join(cache_dir(output_dir, group_id), "state.json") def load_state(output_dir: str, group_id: str) -> Optional[dict]: p = state_path(output_dir, group_id) if not os.path.exists(p): return None try: with open(p) as f: return json.load(f) except Exception: return None def save_state(output_dir: str, group_id: str, state: dict): os.makedirs(cache_dir(output_dir, group_id), exist_ok=True) p = state_path(output_dir, group_id) tmp = p + ".tmp" with open(tmp, "w") as f: json.dump(state, f, indent=2) os.replace(tmp, p) # atomic def init_state( output_dir: str, group_id: str, source_image: str, preferred_filename: str, n_views: int = 24, seed: int = 42, steps: int = 8, ) -> dict: """Create a fresh state dict and save it. Wipes any existing partial state.""" import sys if _HERE not in sys.path: sys.path.insert(0, _HERE) from orbit_qwen import _angles_for angles = _angles_for("turntable", n_views, 180.0) state = { "group_id": str(group_id), "preferred_filename": preferred_filename, "source_image": source_image, "seed": seed, "n_views": n_views, "steps": steps, "angles": angles, "views": {}, # deg_key (str) -> abs path "video_path": None, "completed": False, "started_at": time.time(), "completed_at": None, } save_state(output_dir, group_id, state) return state def deg_key(deg: float) -> str: return f"{deg:.1f}" def mark_view_done(output_dir: str, group_id: str, state: dict, deg: float, path: str): state["views"][deg_key(deg)] = path save_state(output_dir, group_id, state) def mark_completed(output_dir: str, group_id: str, state: dict, video_path: str): state["completed"] = True state["video_path"] = video_path state["completed_at"] = time.time() save_state(output_dir, group_id, state) def next_missing_angle(state: dict) -> Optional[float]: """Return first angle not yet in state['views'], or None if all done.""" done = state.get("views", {}) for deg in state.get("angles", []): if deg_key(deg) not in done: return deg return None def list_cached_group_ids(output_dir: str) -> list: td = os.path.join(output_dir, "_turntable") if not os.path.isdir(td): return [] return [ d for d in os.listdir(td) if os.path.isfile(os.path.join(td, d, "state.json")) ] def get_status_summary(output_dir: str) -> dict: """Return {group_id: status_dict} for all groups that have a state file.""" result = {} for gid in list_cached_group_ids(output_dir): st = load_state(output_dir, gid) if st: result[gid] = { "completed": st.get("completed", False), "n_done": len(st.get("views", {})), "n_total": st.get("n_views", 24), "video_path": st.get("video_path"), "preferred_filename": st.get("preferred_filename"), "started_at": st.get("started_at"), "completed_at": st.get("completed_at"), } return result def get_group_video(output_dir: str, group_id: str) -> Optional[str]: """Return the video path if the turntable is complete and the file exists.""" st = load_state(output_dir, group_id) if not st or not st.get("completed"): return None vp = st.get("video_path") if vp and os.path.exists(vp): return vp return None