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/__init__.py +3 -0
- readalign/__main__.py +8 -0
- readalign/align.py +340 -0
- readalign/asr.py +277 -0
- readalign/audio.py +362 -0
- readalign/cli.py +495 -0
- readalign/clock.py +42 -0
- readalign/epub.py +671 -0
- readalign/errors.py +27 -0
- readalign/fs.py +22 -0
- readalign/members.py +72 -0
- readalign/package.py +287 -0
- readalign/refine.py +175 -0
- readalign/report.py +153 -0
- readalign/sentences.py +78 -0
- readalign/sidecar.py +142 -0
- readalign/smil.py +77 -0
- readalign/textnorm.py +158 -0
- readalign-1.0.0.dist-info/METADATA +332 -0
- readalign-1.0.0.dist-info/RECORD +23 -0
- readalign-1.0.0.dist-info/WHEEL +4 -0
- readalign-1.0.0.dist-info/entry_points.txt +2 -0
- readalign-1.0.0.dist-info/licenses/LICENSE +21 -0
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}")
|