File size: 5,110 Bytes
4e2a1b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
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