""" Legacy project JSON normalizer. Goal: - Convert legacy project JSON (from /opt/gloda-factory/temp/project_*.json) into the script_data schema expected by current Streamlit UI (`app.py`) and composer (`modules/composer.py`). Principles: - Pure rule-based, no AI generation. - Never drop legacy information: keep full raw doc under `script_data["_legacy"]` and per-scene under `scene["_legacy"]`. """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple def _as_str(v: Any) -> str: return v if isinstance(v, str) else "" def _as_dict(v: Any) -> Dict[str, Any]: return v if isinstance(v, dict) else {} def _as_list(v: Any) -> List[Any]: return v if isinstance(v, list) else [] def _detect_schema_variant(doc: Dict[str, Any]) -> str: scenes = _as_list(doc.get("scenes")) if not scenes: return "Unknown" prompt_keys = {"image_prompt", "visual_prompt", "video_prompt"} for s in scenes: if isinstance(s, dict) and (set(s.keys()) & prompt_keys): return "Schema_A" typical_b = {"keyframe", "story_beat", "camera_movement", "image_url"} for s in scenes: if isinstance(s, dict) and (set(s.keys()) & typical_b): return "Schema_B" return "Unknown" def _derive_visual_prompt_from_keyframe(scene: Dict[str, Any]) -> str: """ Build a readable prompt-like summary from keyframe + story_beat. This is NOT an AI prompt; it's a structured description to avoid empty fields. """ keyframe = _as_dict(scene.get("keyframe") or scene.get("keyframes")) story_beat = _as_str(scene.get("story_beat")) parts: List[str] = [] if keyframe: parts.append("[DerivedFromKeyframe]") # deterministic ordering for readability for k in sorted(keyframe.keys()): v = keyframe.get(k) if isinstance(v, (str, int, float)) and str(v).strip(): parts.append(f"{k}: {v}") elif isinstance(v, dict) and v: # flatten one level sub = ", ".join(f"{sk}={sv}" for sk, sv in sorted(v.items()) if str(sv).strip()) if sub: parts.append(f"{k}: {sub}") if story_beat: parts.append(f"story_beat: {story_beat}") return "\n".join(parts).strip() def _derive_video_prompt_from_motion(scene: Dict[str, Any]) -> str: camera_movement = _as_str(scene.get("camera_movement")) rhythm = scene.get("rhythm") story_beat = _as_str(scene.get("story_beat")) parts: List[str] = [] parts.append("[DerivedFromMotion]") if camera_movement: parts.append(f"camera_movement: {camera_movement}") if isinstance(rhythm, dict) and rhythm: # keep stable keys sub = ", ".join(f"{k}={rhythm.get(k)}" for k in sorted(rhythm.keys())) parts.append(f"rhythm: {sub}") if story_beat: parts.append(f"story_beat: {story_beat}") return "\n".join(parts).strip() def _normalize_fancy_text(scene: Dict[str, Any], default_duration: float) -> Dict[str, Any]: ft = scene.get("fancy_text") if isinstance(ft, dict): # Ensure required keys exist out = dict(ft) out.setdefault("text", "") out.setdefault("style", "highlight") # support either position dict or string if "position" not in out: out["position"] = "center" out.setdefault("start_time", 0.0) out.setdefault("duration", default_duration) return out # legacy doesn't have fancy_text return { "text": "", "style": "highlight", "position": "center", "start_time": 0.0, "duration": default_duration, } def _build_voiceover_timeline_from_scenes(normalized_scenes: List[Dict[str, Any]]) -> List[Dict[str, Any]]: timeline: List[Dict[str, Any]] = [] t = 0.0 for idx, s in enumerate(normalized_scenes): dur = float(s.get("duration") or 0) or 0.0 legacy = _as_dict(s.get("_legacy")) vo = _as_str(legacy.get("voiceover") or s.get("voiceover") or "") if vo.strip(): timeline.append( { "id": idx + 1, "text": vo, "subtitle": vo, "start_time": t, "duration": dur if dur > 0 else 3.0, } ) t += dur if dur > 0 else 0.0 return timeline def normalize_legacy_project(doc: Dict[str, Any]) -> Dict[str, Any]: """ Return a script_data dict compatible with current UI. """ schema = _detect_schema_variant(doc) scenes_in = _as_list(doc.get("scenes")) normalized_scenes: List[Dict[str, Any]] = [] for s in scenes_in: if not isinstance(s, dict): continue scene_id = int(s.get("id") or (len(normalized_scenes) + 1)) duration = float(s.get("duration") or 0) or 0.0 if duration <= 0: duration = 3.0 # visual prompt visual_prompt = "" if schema == "Schema_A": # legacy key is usually image_prompt visual_prompt = _as_str(s.get("visual_prompt") or s.get("image_prompt") or "") elif schema == "Schema_B": visual_prompt = _derive_visual_prompt_from_keyframe(s) else: visual_prompt = _as_str(s.get("visual_prompt") or s.get("image_prompt") or "") if not visual_prompt and s.get("keyframe"): visual_prompt = _derive_visual_prompt_from_keyframe(s) # video prompt video_prompt = _as_str(s.get("video_prompt") or "") if not video_prompt: video_prompt = _derive_video_prompt_from_motion(s) # fancy text (default safe) fancy_text = _normalize_fancy_text(s, default_duration=duration) normalized_scene: Dict[str, Any] = { "id": scene_id, "duration": duration, "visual_prompt": visual_prompt, "video_prompt": video_prompt, "fancy_text": fancy_text, # keep optional fields if present "timeline": s.get("timeline", ""), } # Attach per-scene legacy snapshot (do not mutate the original) normalized_scene["_legacy"] = { "schema": schema, "image_url": s.get("image_url"), "keyframe": s.get("keyframe") or s.get("keyframes"), "camera_movement": s.get("camera_movement"), "story_beat": s.get("story_beat"), "rhythm": s.get("rhythm"), "sound_design": s.get("sound_design"), "voiceover": s.get("voiceover"), } normalized_scenes.append(normalized_scene) # voiceover timeline: normalize existing if present, else derive from scenes voiceover vtl = doc.get("voiceover_timeline") voiceover_timeline: List[Dict[str, Any]] = [] if isinstance(vtl, list) and vtl: for idx, it in enumerate(vtl): if not isinstance(it, dict): continue # unify field names text = _as_str(it.get("text") or it.get("voiceover") or "") subtitle = _as_str(it.get("subtitle") or text) start_time = float(it.get("start_time") or 0.0) duration = float(it.get("duration") or 3.0) voiceover_timeline.append( { "id": int(it.get("id") or (idx + 1)), "text": text, "subtitle": subtitle, "start_time": start_time, "duration": duration, } ) else: voiceover_timeline = _build_voiceover_timeline_from_scenes(normalized_scenes) # script_data expected by UI script_data: Dict[str, Any] = { "hook": doc.get("hook", ""), "selling_points": doc.get("selling_points", []) or [], "target_audience": doc.get("target_audience", "") or "", "video_style": doc.get("video_style", "") or "", "bgm_style": doc.get("bgm_style", "") or "", "voiceover_timeline": voiceover_timeline, "scenes": normalized_scenes, "cta": doc.get("cta", ""), # Keep analysis for UI fallback display "analysis": doc.get("analysis", ""), # Preserve original "_legacy": doc, "_legacy_schema": schema, } return script_data