File size: 10,296 Bytes
bec1f06
 
 
 
92ec6d1
 
bec1f06
92ec6d1
bec1f06
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1cfa452
f8e52fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bec1f06
 
1cfa452
 
92ec6d1
 
 
 
 
bec1f06
 
92ec6d1
 
 
 
 
 
 
bec1f06
 
92ec6d1
 
 
bec1f06
 
 
 
 
 
 
 
 
 
 
a85f85c
 
 
f8e52fb
 
 
a85f85c
bec1f06
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8e52fb
 
 
 
 
 
 
 
 
 
bec1f06
 
 
 
 
 
92ec6d1
bec1f06
92ec6d1
bec1f06
 
 
 
92ec6d1
bec1f06
 
92ec6d1
bec1f06
 
 
 
 
 
 
 
 
 
 
92ec6d1
bec1f06
92ec6d1
f8e52fb
92ec6d1
c0fda6d
 
 
 
 
 
 
 
 
 
 
 
bec1f06
c0fda6d
 
 
 
 
 
bec1f06
c0fda6d
 
 
 
 
 
 
 
 
 
 
 
 
bec1f06
 
f8e52fb
1cfa452
bec1f06
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a85f85c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8e52fb
 
 
 
a85f85c
bec1f06
 
 
f8e52fb
 
 
1cfa452
 
bec1f06
 
 
 
 
 
 
 
 
 
 
 
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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
"""Transcript parsing and display helpers."""

from __future__ import annotations

import html
import json
import math
import re
from typing import Any


def format_time(seconds: Any) -> str:
    try:
        value = float(seconds or 0)
    except (TypeError, ValueError):
        value = 0.0
    if not math.isfinite(value):
        value = 0.0
    total = max(0, int(value))
    hours, remainder = divmod(total, 3600)
    minutes, seconds = divmod(remainder, 60)
    return f"{hours:02d}:{minutes:02d}:{seconds:02d}" if hours else f"{minutes:02d}:{seconds:02d}"


def estimate_duration(text: Any) -> float:
    """Give a timestamp-only final sentence a short playable interval."""
    word_count = len(str(text or "").split())
    return round(max(1.0, min(30.0, word_count / 2.8)), 3)


def render_transcript_html(segments: list[dict[str, Any]], visible: bool = True) -> str:
    """Render transcript controls, retaining them in the DOM when text is hidden."""
    if not segments:
        return (
            '<div class="panel"><div class="panel-title">📝 Transcript</div>'
            '<div class="empty">Chưa có transcript.</div></div>'
        )
    rows = []
    for index, segment in enumerate(segments):
        try:
            start = max(0.0, float(segment.get("start", 0) or 0))
            duration = max(0.0, float(segment.get("duration", 0) or 0))
        except (TypeError, ValueError):
            start, duration = 0.0, 0.0
        end = start + duration
        sentence = html.escape(str(segment.get("text", "")), quote=False)
        rows.append(
            f'<button type="button" class="line" data-index="{index}" '
            f'data-start="{start:.3f}" data-end="{end:.3f}" '
            f'aria-label="Phát câu {index + 1}, bắt đầu lúc {format_time(start)}">'
            f'<span class="time">{format_time(start)}</span>'
            f'<span class="line-text">{sentence}</span></button>'
        )
    hidden_class = " transcript-hidden" if not visible else ""
    return (
        '<div class="panel"><div class="panel-title"><span>📝 Transcript</span>'
        f'<span class="count">{len(segments):,} câu</span></div>'
        f'<div class="lines{hidden_class}">{"".join(rows)}</div></div>'
    )


def to_json(segments: Any) -> str:
    return json.dumps(segments or [], ensure_ascii=False, indent=2)


_TIMESTAMP = re.compile(
    r"(?P<start>\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})\s+-->\s+"
    r"(?P<end>\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})"
)


def _seconds(value: str) -> float:
    parts = value.replace(",", ".").split(":")
    if len(parts) == 3:
        return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
    if len(parts) == 2:
        return int(parts[0]) * 60 + float(parts[1])
    return float(parts[0])


def _clean_caption(value: str) -> str:
    value = re.sub(r"<[^>]+>", "", value)
    return re.sub(r"\s+", " ", html.unescape(value)).strip()


def _json_segments(data: Any, video_id: str | None = None) -> Any:
    if isinstance(data, list):
        return data
    if not isinstance(data, dict):
        return []
    if isinstance(data.get("videos"), list):
        videos = data["videos"]
        video = next(
            (item for item in videos if isinstance(item, dict) and item.get("video_id") == video_id),
            None,
        ) if video_id else None
        if video is None and len(videos) == 1:
            only_video = videos[0]
            if isinstance(only_video, dict):
                # Importing a one-video file overlays the currently selected lesson.
                # Its source ID can differ from the lesson ID in the UI.
                video = only_video
        if not isinstance(video, dict):
            return []
        return video.get("segments") or video.get("transcript") or []
    return data.get("transcript") or data.get("segments") or []


def parse_manual(raw: str, video_id: str | None = None) -> list[dict[str, Any]]:
    """Parse JSON, SRT, WebVTT, or ``[m:ss] text`` into normalized segments."""
    raw = raw or ""
    if not raw.strip():
        return []
    stripped = raw.lstrip()
    is_json = stripped.startswith("{") or (
        stripped.startswith("[") and stripped[1:].lstrip().startswith(("{", "["))
    )
    if is_json:
        data = json.loads(raw)
        source = _json_segments(data, video_id)
        if (
            not source
            and isinstance(data, dict)
            and isinstance(data.get("videos"), list)
            and len(data["videos"]) > 1
            and not any(
                isinstance(item, dict) and item.get("video_id") == video_id
                for item in data["videos"]
            )
        ):
            raise ValueError("JSON có nhiều bài; hãy chọn bài học tương ứng trước khi xem trước.")
        return normalize(source)

    out: list[dict[str, Any]] = []
    lines = raw.replace("\ufeff", "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
    i = 0
    while i < len(lines):
        cue = _TIMESTAMP.search(lines[i])
        if cue:
            start = _seconds(cue.group("start"))
            end = _seconds(cue.group("end"))
            i += 1
            text: list[str] = []
            while i < len(lines) and lines[i].strip():
                if _TIMESTAMP.search(lines[i]):
                    break
                text.append(lines[i].strip())
                i += 1
            caption = _clean_caption(" ".join(text))
            if caption and math.isfinite(start) and math.isfinite(end) and end >= start:
                out.append(
                    {
                        "index": len(out),
                        "start": start,
                        "duration": end - start,
                        "text": caption,
                    }
                )
            continue
        i += 1
    if out:
        return normalize(out)

    # YouTube's transcript panel commonly copies each timestamp and caption
    # onto separate lines. Also accept the compact ``[m:ss] caption`` form.
    current: dict[str, Any] | None = None
    timestamp_line = re.compile(
        r"^[\[(]?(\d{1,2}:\d{2}(?::\d{2})?(?:[.,]\d{1,3})?)[\])]?(?:\s+(.*))?$"
    )

    def append_current() -> None:
        if current is None:
            return
        caption = _clean_caption(" ".join(current["text"]))
        if caption and math.isfinite(current["start"]):
            out.append(
                {
                    "index": len(out),
                    "start": current["start"],
                    "duration": 0.0,
                    "text": caption,
                }
            )

    for source_line in lines:
        line = source_line.strip()
        match = timestamp_line.match(line)
        if match:
            append_current()
            start = _seconds(match.group(1))
            caption = _clean_caption(match.group(2) or "")
            current = {"start": start, "text": [caption] if caption else []}
        elif current is not None and line:
            current["text"].append(line)
    append_current()

    for index in range(len(out) - 1):
        out[index]["duration"] = max(0.0, out[index + 1]["start"] - out[index]["start"])
    return normalize(out)


def normalize(raw: Any) -> list[dict[str, Any]]:
    out = []
    for item in raw or []:
        if not isinstance(item, dict):
            continue
        text = _clean_caption(str(item.get("text") or item.get("utf8") or ""))
        if not text:
            continue
        try:
            start = float(item.get("start", item.get("start_time", 0)) or 0)
        except (TypeError, ValueError):
            continue
        if not math.isfinite(start) or start < 0:
            continue
        try:
            duration = float(item.get("duration", 0) or 0)
            if item.get("end") is not None or item.get("end_time") is not None:
                end = float(item.get("end", item.get("end_time")) or start)
                duration = end - start
        except (TypeError, ValueError):
            duration = 0.0
        if not math.isfinite(duration) or duration < 0:
            duration = 0.0
        segment = {
            "index": len(out),
            "start": start,
            "duration": duration,
            "text": text,
        }
        words = item.get("words")
        if isinstance(words, list):
            aligned_words = []
            for word in words:
                if not isinstance(word, dict):
                    continue
                token = _clean_caption(str(word.get("word") or ""))
                try:
                    word_start = float(word.get("start"))
                    word_end = float(word.get("end", word_start))
                except (TypeError, ValueError):
                    continue
                if (
                    token
                    and math.isfinite(word_start)
                    and math.isfinite(word_end)
                    and 0 <= word_start <= word_end
                ):
                    aligned_words.append(
                        {"word": token, "start": word_start, "end": word_end}
                    )
            if aligned_words:
                segment["words"] = aligned_words
                if not segment["duration"]:
                    word_end = max(word["end"] for word in aligned_words)
                    if word_end > start:
                        segment["duration"] = word_end - start
        out.append(segment)
    for index in range(len(out) - 1):
        if not out[index]["duration"]:
            out[index]["duration"] = max(0.0, out[index + 1]["start"] - out[index]["start"])
    for segment in out:
        if not segment["duration"]:
            segment["duration"] = estimate_duration(segment["text"])
    return out


def active_index(segments: list[dict[str, Any]], current_time: float) -> int:
    try:
        timestamp = float(current_time or 0)
    except (TypeError, ValueError):
        timestamp = 0.0
    for index, item in enumerate(segments):
        start = float(item.get("start", 0) or 0)
        end = start + max(float(item.get("duration", 0) or 0), 0.25)
        if start <= timestamp < end:
            return index
    return max(0, len(segments) - 1) if segments and timestamp >= segments[-1]["start"] else -1