from __future__ import annotations import re from typing import Any CLAUSE_SPLIT_RE = re.compile(r"[。!?!?;;]+|\s*\n+\s*") PAUSE_SPLIT_RE = re.compile(r"[,、:,:]+") SYNC_PREFIX = "按旁白节奏同步:" def _clean_text(value: Any) -> str: return re.sub(r"\s+", " ", str(value or "")).strip() def split_script_beats(text: Any, max_beats: int = 3) -> list[str]: value = _clean_text(text) if not value: return [] parts = [part.strip(" ,、;;。!!??") for part in CLAUSE_SPLIT_RE.split(value) if part.strip(" ,、;;。!!??")] if len(parts) <= 1: fallback = [part.strip(" ,、;;。!!??") for part in PAUSE_SPLIT_RE.split(value) if part.strip(" ,、;;。!!??")] if 1 < len(fallback) <= max_beats: parts = fallback if not parts: parts = [value] while len(parts) > max_beats: parts[-2] = f"{parts[-2]},{parts[-1]}" parts.pop() return parts[:max_beats] def _motion_hint(index: int, total: int) -> str: if total <= 1: return "镜头轻微推进,保持主体与情绪稳定" if index == 0: return "先交代人物或核心物象与时代环境,轻微推进起势" if index == total - 1: return "收束到教学重点或情绪落点,运动减缓并稳定停留" return "顺着旁白推进主体动作或空间变化,保持节奏连续" def _sanitize_existing(items: Any) -> list[dict[str, str]]: cleaned: list[dict[str, str]] = [] if not isinstance(items, list): return cleaned for item in items[:3]: if not isinstance(item, dict): continue normalized = { "time_range": _clean_text(item.get("time_range", "")), "audio_cue": _clean_text(item.get("audio_cue", "")), "visual_cue": _clean_text(item.get("visual_cue", "")), "motion_cue": _clean_text(item.get("motion_cue", "")), } if any(normalized.values()): cleaned.append(normalized) return cleaned def build_default_av_sync_plan(audio_text: Any, duration: int, existing: Any = None) -> list[dict[str, str]]: cleaned_existing = _sanitize_existing(existing) if cleaned_existing: return cleaned_existing beats = split_script_beats(audio_text, max_beats=3) if not beats: return [] total_seconds = max(1, int(duration or 1)) item_count = len(beats) plans: list[dict[str, str]] = [] start_second = 0 for index, beat in enumerate(beats): if index == item_count - 1: end_second = total_seconds else: end_second = max(start_second + 1, round(total_seconds * (index + 1) / item_count)) plans.append( { "time_range": f"{start_second:02d}-{end_second:02d}s", "audio_cue": beat, "visual_cue": beat, "motion_cue": _motion_hint(index, item_count), } ) start_second = end_second return plans def derive_full_script(segments: Any) -> str: lines = [] for segment in segments or []: text = _clean_text(getattr(segment, "audio_text", "")) if text: lines.append(text) return "\n".join(lines) def apply_script_first_sync(plan: Any) -> None: segments = list(getattr(plan, "segments", []) or []) current_full_script = _clean_text(getattr(plan, "full_script", "")) if not current_full_script: setattr(plan, "full_script", derive_full_script(segments)) for segment in segments: script_beat = _clean_text(getattr(segment, "script_beat", "")) if not script_beat: script_beat = _clean_text(getattr(segment, "audio_text", "")) setattr(segment, "script_beat", script_beat) av_sync_plan = build_default_av_sync_plan( script_beat or getattr(segment, "audio_text", ""), int(getattr(segment, "duration", 1) or 1), existing=getattr(segment, "av_sync_plan", []), ) setattr(segment, "av_sync_plan", av_sync_plan) def build_sync_suffix(items: Any) -> str: cleaned = _sanitize_existing(items) if not cleaned: return "" snippets = [] for item in cleaned: snippets.append( f"{item['time_range']} {item['audio_cue']}→{item['visual_cue']}/{item['motion_cue']}".strip() ) return SYNC_PREFIX + ";".join(snippets) def strip_sync_suffix(prompt: Any) -> str: parts = [part.strip() for part in re.split(r"[;;]", _clean_text(prompt)) if part.strip()] kept = [part for part in parts if not part.startswith(SYNC_PREFIX)] return ";".join(kept) def upsert_sync_suffix(prompt: Any, items: Any, limit: int | None = None) -> str: base = strip_sync_suffix(prompt) suffix = build_sync_suffix(items) combined = f"{base};{suffix}" if base and suffix else (suffix or base) if limit is not None and len(combined) > int(limit): combined = combined[: max(0, int(limit) - 1)].rstrip(";,,。 ") + "…" return combined