readalign 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
readalign/cli.py ADDED
@@ -0,0 +1,495 @@
1
+ """Command line entry point: ``readalign build`` and ``readalign check``.
2
+
3
+ Everything below argparse is imported inside the function that needs it. Pulling in lxml and the
4
+ rest of the package costs 80ms, which is more than ``readalign --version`` should ever take.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import posixpath
13
+ import shutil
14
+ import sys
15
+ import tempfile
16
+ import time
17
+ import zipfile
18
+ from urllib.parse import unquote
19
+
20
+ from . import __version__
21
+ from .errors import InputError, ReadAlignError
22
+ from .members import open_archive
23
+
24
+ DEFAULT_MODEL = "large-v3-turbo"
25
+ DEFAULT_DRIFT = 2.5
26
+
27
+
28
+ def _cache_root() -> str:
29
+ if os.name == "nt":
30
+ base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
31
+ return os.path.join(base, "readalign", "cache")
32
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
33
+ return os.path.join(base, "readalign")
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ parser = argparse.ArgumentParser(
38
+ prog="readalign",
39
+ description="Turn a DRM-free audiobook and its ebook into one EPUB 3 with media overlays.",
40
+ )
41
+ parser.add_argument("--version", action="version", version=f"readalign {__version__}")
42
+ subparsers = parser.add_subparsers(dest="command", required=True)
43
+
44
+ build = subparsers.add_parser("build", help="align an audiobook to an ebook and write an EPUB")
45
+ build.add_argument("--audio", required=True,
46
+ help="audio file, or a directory of numbered files, or a glob")
47
+ build.add_argument("--epub", required=True, help="the matching DRM-free EPUB")
48
+ build.add_argument("--out", required=True, help="path of the EPUB to write")
49
+ build.add_argument("--model", default=DEFAULT_MODEL,
50
+ help=f"faster-whisper model (default: {DEFAULT_MODEL})")
51
+ build.add_argument("--language", default=None,
52
+ help="ISO language code of the narration (default: detect)")
53
+ build.add_argument("--drift-threshold", type=float, default=DEFAULT_DRIFT, metavar="SECONDS",
54
+ help="fail if any sentence drifts more than this "
55
+ f"(default: {DEFAULT_DRIFT})")
56
+ sidecars = build.add_mutually_exclusive_group()
57
+ sidecars.add_argument("--sidecars", dest="sidecars", action="store_true", default=True,
58
+ help="write WebVTT and align.json next to the output (default)")
59
+ sidecars.add_argument("--no-sidecars", dest="sidecars", action="store_false",
60
+ help="write only the EPUB and the report")
61
+ build.add_argument("--dry-run", action="store_true",
62
+ help="probe the inputs and print the plan without transcribing")
63
+ build.add_argument("--narrator", default=None, help="value for the media:narrator metadata")
64
+ build.add_argument("--refine", action="store_true",
65
+ help="sharpen boundaries with the MMS CTC aligner "
66
+ "(needs readalign[refine]; that model is CC BY-NC 4.0)")
67
+ build.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"],
68
+ help="where to run the recogniser (default: auto)")
69
+ build.add_argument("--beam-size", type=int, default=5, help="whisper beam size (default: 5)")
70
+ build.add_argument("--no-vad", dest="vad", action="store_false", default=True,
71
+ help="disable the voice-activity filter")
72
+ build.add_argument("--audio-bitrate", default="96k",
73
+ help="bitrate used when audio has to be transcoded (default: 96k)")
74
+ build.add_argument("--work-dir", default=None,
75
+ help="scratch directory to use instead of a temporary one")
76
+ build.add_argument("--cache-dir", default=None,
77
+ help=f"transcript cache (default: {_cache_root()})")
78
+ build.add_argument("--no-cache", dest="cache", action="store_false", default=True,
79
+ help="do not read or write the transcript cache")
80
+ build.add_argument("--quiet", action="store_true", help="only print errors and the summary")
81
+
82
+ check = subparsers.add_parser("check", help="validate the media overlays in an EPUB")
83
+ check.add_argument("epub", help="the EPUB to inspect")
84
+ check.add_argument("--json", action="store_true", help="print the findings as JSON")
85
+ check.add_argument("--no-audio-probe", dest="probe", action="store_false", default=True,
86
+ help="skip the ffprobe duration check")
87
+ return parser
88
+
89
+
90
+ def main(argv: list[str] | None = None) -> int:
91
+ parser = build_parser()
92
+ args = parser.parse_args(argv)
93
+ try:
94
+ if args.command == "build":
95
+ return run_build(args)
96
+ return run_check(args)
97
+ except ReadAlignError as exc:
98
+ print(f"readalign: {exc}", file=sys.stderr)
99
+ return exc.exit_code
100
+ except KeyboardInterrupt:
101
+ print("readalign: interrupted", file=sys.stderr)
102
+ return 130
103
+ except BrokenPipeError: # pragma: no cover - only when output is piped into head
104
+ return 0
105
+
106
+
107
+ def _dry_run_plan(package, tracks, log) -> int:
108
+ log("")
109
+ log("dry run, nothing will be transcribed or written")
110
+ log(f" ebook {package.title() or '(untitled)'} EPUB {package.version}")
111
+ log(f" documents {len(package.docs)} of {len(package.spine_ids)} spine items carry text")
112
+ log(f" sentences {sum(len(doc.sentences) for doc in package.docs)}")
113
+ total = sum(track.duration for track in tracks)
114
+ log(f" audio {len(tracks)} file(s), {total / 3600:.2f} hours")
115
+ for track in tracks:
116
+ note = " (transcode)" if track.transcoded else " (stream copy)"
117
+ log(f" {track.index:>3} {os.path.basename(track.source)} "
118
+ f"{track.duration / 60:.1f} min -> {track.media_type}{note}")
119
+ return 0
120
+
121
+
122
+ def run_build(args) -> int:
123
+ from .align import align
124
+ from .audio import decode_wav, discover_inputs, prepare_tracks, require_ffmpeg
125
+ from .epub import load_epub
126
+ from .package import build_output, ensure_mimetype, write_epub
127
+ from .report import build_report, document_stats, format_summary, write_report
128
+
129
+ log = (lambda *_: None) if args.quiet else print
130
+ started = time.monotonic()
131
+ warnings: list[str] = []
132
+
133
+ if args.drift_threshold <= 0:
134
+ raise InputError("--drift-threshold must be a positive number of seconds")
135
+ out_path = os.path.abspath(args.out)
136
+ if os.path.isdir(out_path):
137
+ raise InputError(f"--out points at a directory: {out_path}")
138
+ if out_path == os.path.abspath(args.epub):
139
+ raise InputError("--out is the same file as --epub, which would overwrite your ebook")
140
+
141
+ require_ffmpeg()
142
+ log(f"readalign {__version__}")
143
+ log(f"reading {args.epub}")
144
+ package = load_epub(args.epub)
145
+ sentences = [sentence for doc in package.docs for sentence in doc.sentences]
146
+ if not sentences:
147
+ raise InputError(
148
+ f"{args.epub} has spine documents but no sentences in them. It may be a "
149
+ "fixed-layout or image-only EPUB, which readalign cannot narrate."
150
+ )
151
+ log(f" {len(package.docs)} content documents, {len(sentences)} sentences")
152
+
153
+ sources = discover_inputs(args.audio)
154
+ log(f"preparing {len(sources)} audio file(s)")
155
+ work_dir = args.work_dir or tempfile.mkdtemp(prefix="readalign-")
156
+ temporary = args.work_dir is None
157
+ os.makedirs(work_dir, exist_ok=True)
158
+ try:
159
+ tracks = prepare_tracks(sources, work_dir, args.audio_bitrate, log=log)
160
+ if args.dry_run:
161
+ return _dry_run_plan(package, tracks, log)
162
+
163
+ for track in tracks:
164
+ decode_wav(track, work_dir)
165
+
166
+ cache_dir = None
167
+ if args.cache:
168
+ cache_dir = args.cache_dir or _cache_root()
169
+ log("transcribing")
170
+ from .asr import transcribe_tracks
171
+
172
+ words, asr_info = transcribe_tracks(
173
+ tracks,
174
+ model_name=args.model,
175
+ device=args.device,
176
+ language=args.language,
177
+ vad=args.vad,
178
+ beam_size=args.beam_size,
179
+ cache_dir=cache_dir,
180
+ log=log,
181
+ )
182
+ if asr_info["cached_tracks"] == len(tracks):
183
+ log(f" {len(words)} words from cache in {asr_info['seconds']}s")
184
+ else:
185
+ log(f" {len(words)} words in {asr_info['seconds']}s on {asr_info['device']}")
186
+
187
+ log("aligning")
188
+ result = align(sentences, words, tracks, log=log)
189
+ if args.refine:
190
+ from .refine import refine
191
+
192
+ asr_info["refine"] = refine(result, tracks, device=args.device, log=log)
193
+ warnings.append(
194
+ "built with --refine: the MMS-300m aligner is CC BY-NC 4.0, so these timings "
195
+ "are for non-commercial use"
196
+ )
197
+
198
+ entries_by_doc: dict[str, list] = {}
199
+ for entry in result.timed:
200
+ doc = package.docs[entry.sentence.doc_index]
201
+ entries_by_doc.setdefault(doc.manifest_id, []).append(entry)
202
+ if not entries_by_doc:
203
+ raise InputError(
204
+ "no sentence could be timed, so there is nothing to write. Check that the audio "
205
+ "and the ebook are the same work."
206
+ )
207
+
208
+ files, summary = build_output(package, entries_by_doc, tracks, narrator=args.narrator)
209
+ ensure_mimetype(files)
210
+ write_epub(out_path, files, package.order)
211
+ log(f"wrote {out_path} ({os.path.getsize(out_path) / 1_048_576:.1f} MB, "
212
+ f"{summary['overlays']} overlays, {summary['total_duration']})")
213
+
214
+ stats = document_stats(package, result)
215
+ for doc in package.docs:
216
+ if doc.recovered:
217
+ warnings.append(f"{doc.href} had malformed XML and was parsed in recovery mode")
218
+ if doc.reencoded_from:
219
+ warnings.append(f"{doc.href} was decoded as {doc.reencoded_from}, not UTF-8")
220
+ if result.unaligned_regions:
221
+ warnings.append(
222
+ f"{len(result.unaligned_regions)} stretch(es) of the book had no match in the "
223
+ "narration and were timed by interpolation"
224
+ )
225
+ report = build_report(
226
+ package, result, tracks, stats,
227
+ output=out_path,
228
+ asr_info=asr_info,
229
+ drift_threshold=args.drift_threshold,
230
+ warnings=warnings,
231
+ extras={"elapsed_seconds": round(time.monotonic() - started, 1),
232
+ "overlay_duration": summary["total_duration"]},
233
+ )
234
+ report_path = os.path.join(os.path.dirname(out_path) or ".", "readalign-report.json")
235
+ write_report(report_path, report)
236
+
237
+ if args.sidecars:
238
+ from .sidecar import write_sidecars
239
+
240
+ directory = os.path.join(
241
+ os.path.dirname(out_path) or ".",
242
+ os.path.splitext(os.path.basename(out_path))[0] + "-sidecars",
243
+ )
244
+ written = write_sidecars(
245
+ directory, result, tracks,
246
+ {index: doc.href for index, doc in enumerate(package.docs)},
247
+ summary["audio_hrefs"],
248
+ title=package.title(),
249
+ )
250
+ log(f"wrote {len(written)} sidecar file(s) to {directory}")
251
+ finally:
252
+ if temporary:
253
+ shutil.rmtree(work_dir, ignore_errors=True)
254
+
255
+ print(format_summary(report))
256
+ print(f" report {report_path}")
257
+ if not report["drift"]["within_threshold"]:
258
+ print(
259
+ f"readalign: max drift {report['drift']['max']:.2f}s exceeds the "
260
+ f"--drift-threshold of {args.drift_threshold:.2f}s. The EPUB was still written; "
261
+ f"check readalign-report.json for where it slipped.",
262
+ file=sys.stderr,
263
+ )
264
+ return 3
265
+ return 0
266
+
267
+
268
+ def _resolve(base: str, href: str) -> str:
269
+ target = unquote(href.split("#", 1)[0])
270
+ return posixpath.normpath(posixpath.join(posixpath.dirname(base), target))
271
+
272
+
273
+ def _open_package(archive, path: str):
274
+ """Return the package document path and its parsed root, or raise a plain InputError."""
275
+ from lxml import etree
276
+
277
+ from .epub import CONTAINER_NS
278
+
279
+ names = set(archive.namelist())
280
+ if "META-INF/container.xml" not in names:
281
+ raise InputError(f"{path} has no META-INF/container.xml, so it is not an EPUB")
282
+ container = etree.fromstring(archive.read("META-INF/container.xml"))
283
+ rootfile = container.find(f".//{{{CONTAINER_NS}}}rootfile")
284
+ if rootfile is None or not rootfile.get("full-path"):
285
+ raise InputError(f"{path} has no rootfile in META-INF/container.xml")
286
+ opf_path = rootfile.get("full-path")
287
+ if opf_path not in names:
288
+ raise InputError(f"{path} names a package document that is missing: {opf_path}")
289
+ return opf_path, etree.fromstring(archive.read(opf_path))
290
+
291
+
292
+ def _overlay_durations(
293
+ opf, opf_ns: str, errors: list[str]
294
+ ) -> tuple[dict[str, float], float | None]:
295
+ from .clock import parse_clock
296
+
297
+ durations: dict[str, float] = {}
298
+ total: float | None = None
299
+ for meta in opf.iter(f"{{{opf_ns}}}meta"):
300
+ if meta.get("property") != "media:duration":
301
+ continue
302
+ try:
303
+ value = parse_clock((meta.text or "").strip())
304
+ except ValueError as exc:
305
+ errors.append(f"media:duration is not a SMIL clock value: {exc}")
306
+ continue
307
+ refines = meta.get("refines")
308
+ if refines:
309
+ durations[refines.lstrip("#")] = value
310
+ else:
311
+ total = value
312
+ return durations, total
313
+
314
+
315
+ def _fragment_ids(archive, doc_path: str, cache: dict[str, set[str]]) -> set[str]:
316
+ from .epub import element_ids, parse_content_document
317
+
318
+ if doc_path not in cache:
319
+ tree, _, _ = parse_content_document(archive.read(doc_path), doc_path)
320
+ cache[doc_path] = element_ids(tree)
321
+ return cache[doc_path]
322
+
323
+
324
+ def _probe_clip_ends(archive, max_clip_end: dict[str, float], errors: list[str],
325
+ warnings: list[str]) -> dict[str, float]:
326
+ """Acceptance check: no clipEnd may sit past the real duration ffprobe reports."""
327
+ from .audio import ffprobe
328
+
329
+ durations: dict[str, float] = {}
330
+ with tempfile.TemporaryDirectory(prefix="readalign-check-") as scratch:
331
+ for audio_path, clip_end in sorted(max_clip_end.items()):
332
+ extracted = os.path.join(scratch, posixpath.basename(audio_path))
333
+ with archive.open(audio_path) as source, open(extracted, "wb") as handle:
334
+ shutil.copyfileobj(source, handle, 1 << 20)
335
+ try:
336
+ duration = float(ffprobe(extracted)["format"]["duration"])
337
+ except (ReadAlignError, KeyError, ValueError, TypeError) as exc:
338
+ warnings.append(f"could not probe {audio_path}: {exc}")
339
+ continue
340
+ durations[audio_path] = round(duration, 3)
341
+ if clip_end > duration + 0.05:
342
+ errors.append(
343
+ f"{audio_path}: a clipEnd of {clip_end:.3f}s is past the file's real "
344
+ f"duration of {duration:.3f}s"
345
+ )
346
+ return durations
347
+
348
+
349
+ def check_epub(path: str, *, probe_audio: bool = True) -> dict:
350
+ """Validate the media overlays of an EPUB. Structural faults are reported, not raised."""
351
+ from lxml import etree
352
+
353
+ from .clock import parse_clock
354
+ from .epub import OPF_NS
355
+ from .smil import SMIL_NS
356
+
357
+ errors: list[str] = []
358
+ warnings: list[str] = []
359
+ with open_archive(path) as archive:
360
+ names = set(archive.namelist())
361
+ entries = archive.infolist()
362
+ if not entries or entries[0].filename != "mimetype":
363
+ errors.append("the first archive entry is not 'mimetype'")
364
+ elif entries[0].compress_type != zipfile.ZIP_STORED:
365
+ errors.append("the 'mimetype' entry is compressed; OCF requires it stored")
366
+ elif archive.read("mimetype") != b"application/epub+zip":
367
+ errors.append("the 'mimetype' entry does not contain application/epub+zip")
368
+
369
+ opf_path, opf = _open_package(archive, path)
370
+ items = {item.get("id"): item for item in opf.iter(f"{{{OPF_NS}}}item") if item.get("id")}
371
+ spine = [ref.get("idref") for ref in opf.iter(f"{{{OPF_NS}}}itemref") if ref.get("idref")]
372
+ for idref in spine:
373
+ if idref not in items:
374
+ errors.append(f"spine itemref '{idref}' has no manifest item")
375
+ durations, total_meta = _overlay_durations(opf, OPF_NS, errors)
376
+
377
+ fragments: dict[str, set[str]] = {}
378
+ max_clip_end: dict[str, float] = {}
379
+ overlays = 0
380
+ pars = 0
381
+ clip_total = 0.0
382
+
383
+ for idref in spine:
384
+ item = items.get(idref)
385
+ overlay_id = item.get("media-overlay") if item is not None else None
386
+ if not overlay_id:
387
+ continue
388
+ overlays += 1
389
+ overlay = items.get(overlay_id)
390
+ if overlay is None:
391
+ errors.append(
392
+ f"'{idref}' points at media-overlay '{overlay_id}', which is not in the "
393
+ "manifest"
394
+ )
395
+ continue
396
+ if overlay.get("media-type") != "application/smil+xml":
397
+ errors.append(
398
+ f"overlay '{overlay_id}' is declared as {overlay.get('media-type')}, not "
399
+ "application/smil+xml"
400
+ )
401
+ smil_path = _resolve(opf_path, overlay.get("href", ""))
402
+ if smil_path not in names:
403
+ errors.append(f"overlay '{overlay_id}' is missing from the archive: {smil_path}")
404
+ continue
405
+ if overlay_id not in durations:
406
+ warnings.append(f"overlay '{overlay_id}' has no media:duration metadata")
407
+ smil = etree.fromstring(archive.read(smil_path))
408
+ overlay_clip = 0.0
409
+ for par in smil.iter(f"{{{SMIL_NS}}}par"):
410
+ pars += 1
411
+ text = par.find(f"{{{SMIL_NS}}}text")
412
+ audio = par.find(f"{{{SMIL_NS}}}audio")
413
+ if text is None or not text.get("src"):
414
+ errors.append(f"{smil_path}: a par has no text src")
415
+ continue
416
+ src = text.get("src")
417
+ doc_path = _resolve(smil_path, src)
418
+ fragment = unquote(src.split("#", 1)[1]) if "#" in src else ""
419
+ if doc_path not in names:
420
+ errors.append(f"{smil_path}: text points at a missing document {doc_path}")
421
+ elif fragment and fragment not in _fragment_ids(archive, doc_path, fragments):
422
+ errors.append(f"{smil_path}: no element '{fragment}' in {doc_path}")
423
+ if audio is None or not audio.get("src"):
424
+ errors.append(f"{smil_path}: a par has no audio src")
425
+ continue
426
+ audio_path = _resolve(smil_path, audio.get("src"))
427
+ if audio_path not in names:
428
+ errors.append(f"{smil_path}: audio points at a missing file {audio_path}")
429
+ continue
430
+ try:
431
+ begin = parse_clock(audio.get("clipBegin", "0s"))
432
+ end = parse_clock(audio.get("clipEnd", "0s"))
433
+ except ValueError as exc:
434
+ errors.append(f"{smil_path}: {exc}")
435
+ continue
436
+ if end <= begin:
437
+ errors.append(f"{smil_path}: clipEnd {end} is not after clipBegin {begin}")
438
+ overlay_clip += max(0.0, end - begin)
439
+ max_clip_end[audio_path] = max(max_clip_end.get(audio_path, 0.0), end)
440
+ clip_total += overlay_clip
441
+ declared = durations.get(overlay_id)
442
+ if declared is not None and abs(declared - overlay_clip) > 1.0:
443
+ warnings.append(
444
+ f"overlay '{overlay_id}' declares {declared:.3f}s but its clips total "
445
+ f"{overlay_clip:.3f}s"
446
+ )
447
+
448
+ audio_durations = (
449
+ _probe_clip_ends(archive, max_clip_end, errors, warnings)
450
+ if probe_audio and max_clip_end
451
+ else {}
452
+ )
453
+ if overlays == 0:
454
+ errors.append("no spine document has a media-overlay attribute")
455
+ if total_meta is None and overlays:
456
+ warnings.append("the package has no unrefined media:duration for the whole book")
457
+
458
+ return {
459
+ "epub": os.path.abspath(path),
460
+ "overlays": overlays,
461
+ "sentences": pars,
462
+ "clip_seconds": round(clip_total, 3),
463
+ "declared_total": round(total_meta, 3) if total_meta is not None else None,
464
+ "audio_durations": audio_durations,
465
+ "errors": errors,
466
+ "warnings": warnings,
467
+ "ok": not errors,
468
+ }
469
+
470
+
471
+ def run_check(args) -> int:
472
+ findings = check_epub(args.epub, probe_audio=args.probe)
473
+ if args.json:
474
+ print(json.dumps(findings, ensure_ascii=False, indent=2))
475
+ return 0 if findings["ok"] else 1
476
+
477
+ print(f"checking {findings['epub']}")
478
+ print(f" overlays {findings['overlays']}")
479
+ print(f" sentences {findings['sentences']}")
480
+ print(f" clip time {findings['clip_seconds'] / 3600:.2f} hours")
481
+ if findings["declared_total"] is not None:
482
+ print(f" media:duration {findings['declared_total'] / 3600:.2f} hours declared")
483
+ for warning in findings["warnings"][:20]:
484
+ print(f" warning {warning}")
485
+ for error in findings["errors"][:20]:
486
+ print(f" error {error}", file=sys.stderr)
487
+ hidden = len(findings["errors"]) - 20
488
+ if hidden > 0:
489
+ print(f" error and {hidden} more", file=sys.stderr)
490
+ print(" result " + ("ok" if findings["ok"] else "failed"))
491
+ return 0 if findings["ok"] else 1
492
+
493
+
494
+ if __name__ == "__main__": # pragma: no cover
495
+ sys.exit(main())
readalign/clock.py ADDED
@@ -0,0 +1,42 @@
1
+ """SMIL 3.0 clock values, as used by ``clipBegin``/``clipEnd`` and ``media:duration``.
2
+
3
+ The full SMIL grammar allows several forms; readalign writes the ``H:MM:SS.mmm`` full clock
4
+ value everywhere and reads back every form it may meet in a third-party package.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ _FULL = re.compile(r"^(\d+):([0-5]\d):([0-5]\d(?:\.\d+)?)$")
12
+ _PARTIAL = re.compile(r"^([0-5]?\d):([0-5]\d(?:\.\d+)?)$")
13
+ _TIMECOUNT = re.compile(r"^(\d+(?:\.\d+)?)\s*(h|min|s|ms)?$")
14
+
15
+ _UNIT_SECONDS = {"h": 3600.0, "min": 60.0, "s": 1.0, "ms": 0.001, None: 1.0}
16
+
17
+
18
+ def format_clock(seconds: float) -> str:
19
+ """Render seconds as a SMIL full clock value, e.g. ``0:12:34.120``."""
20
+ if seconds < 0:
21
+ seconds = 0.0
22
+ # Round to milliseconds first so 59.9996 does not render as 0:00:60.000.
23
+ total_ms = round(seconds * 1000)
24
+ hours, rest = divmod(total_ms, 3_600_000)
25
+ minutes, rest = divmod(rest, 60_000)
26
+ secs, millis = divmod(rest, 1000)
27
+ return f"{hours}:{minutes:02d}:{secs:02d}.{millis:03d}"
28
+
29
+
30
+ def parse_clock(value: str) -> float:
31
+ """Parse any SMIL clock value into seconds."""
32
+ text = value.strip()
33
+ match = _FULL.match(text)
34
+ if match:
35
+ return int(match[1]) * 3600 + int(match[2]) * 60 + float(match[3])
36
+ match = _PARTIAL.match(text)
37
+ if match:
38
+ return int(match[1]) * 60 + float(match[2])
39
+ match = _TIMECOUNT.match(text)
40
+ if match:
41
+ return float(match[1]) * _UNIT_SECONDS[match[2]]
42
+ raise ValueError(f"not a SMIL clock value: {value!r}")