Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


backend/render.py (9971 bytes)

1 """Render a YouTube-ready MP4: cover image, the audio, and a soft subtitle track.
2 
3 The subtitles are a selectable track rather than burned into the picture, so the
4 file stays small, the encode stays fast, and the viewer can turn captions off.
5 YouTube reads the track on upload; the .srt is also offered on its own for
6 people who would rather attach it there.
7 
8 The encode is cheap by construction: one still frame per second over a canvas
9 that is scaled once up front, so ffmpeg never recomputes the scale per frame.
10 """
11 
12 from __future__ import annotations
13 
14 import logging
15 import subprocess
16 import zipfile
17 from functools import lru_cache
18 from pathlib import Path
19 
20 from .settings import settings
21 
22 log = logging.getLogger(__name__)
23 
24 # Cover art we are willing to pull out of an epub.
25 _IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
26 
27 # H.264 encoders in order of preference. ffmpeg builds vary wildly in what they
28 # include - libx264 is the usual default but is absent from plenty of builds,
29 # so pick from what is actually compiled in rather than assuming.
30 _H264_PREFERENCE = [
31     "libx264",      # best quality per bit, most common
32     "h264_nvenc",   # NVIDIA
33     "h264_qsv",     # Intel Quick Sync
34     "h264_amf",     # AMD
35     "libopenh264",  # software fallback, always safe
36     "h264_mf",      # Windows Media Foundation
37 ]
38 
39 # Encoder-specific quality flags. A libx264 -crf/-preset means nothing to the
40 # others and makes ffmpeg fail outright.
41 _ENCODER_FLAGS: dict[str, list[str]] = {
42     "libx264": ["-preset", "veryfast", "-tune", "stillimage"],
43     "libopenh264": ["-b:v", "1M"],
44     "h264_nvenc": ["-preset", "p4", "-tune", "ll"],
45     "h264_qsv": ["-preset", "veryfast"],
46     "h264_amf": ["-quality", "speed", "-rc", "cqp"],
47     "h264_mf": [],
48 }
49 
50 
51 class RenderError(RuntimeError):
52     pass
53 
54 
55 @lru_cache(maxsize=1)
56 def available_encoders() -> set[str]:
57     try:
58         out = subprocess.run(
59             ["ffmpeg", "-hide_banner", "-encoders"],
60             capture_output=True, timeout=60,
61         ).stdout.decode("utf-8", errors="replace")
62     except (subprocess.SubprocessError, OSError):
63         return set()
64     names = set()
65     for line in out.splitlines():
66         parts = line.split()
67         # Rows look like " V....D libopenh264  OpenH264 ..." - the name is [1].
68         if len(parts) >= 2 and parts[0].startswith("V"):
69             names.add(parts[1])
70     return names
71 
72 
73 def encoder_works(name: str) -> bool:
74     """Actually encode one frame with `name`.
75 
76     Being listed by `ffmpeg -encoders` only means it was compiled in. The
77     hardware encoders are listed on machines with no such hardware and fail at
78     runtime, so the only trustworthy check is to run one.
79     """
80     cmd = [
81         "ffmpeg", "-hide_banner", "-v", "error",
82         "-f", "lavfi", "-i", "color=c=black:s=320x240:d=0.1",
83         "-frames:v", "1", "-c:v", name,
84         *_ENCODER_FLAGS.get(name, []),
85         "-pix_fmt", "yuv420p", "-f", "null", "-",
86     ]
87     try:
88         return subprocess.run(cmd, capture_output=True, timeout=60).returncode == 0
89     except (subprocess.SubprocessError, OSError):
90         return False
91 
92 
93 @lru_cache(maxsize=1)
94 def resolve_encoder() -> str:
95     """The H.264 encoder to use, honouring an explicit setting if it works."""
96     have = available_encoders()
97     configured = settings.video_encoder
98 
99     if configured and configured != "auto":
100         if encoder_works(configured):
101             return configured
102         log.warning(
103             "video_encoder=%r does not work on this machine; falling back",
104             configured,
105         )
106 
107     for name in _H264_PREFERENCE:
108         if name in have and encoder_works(name):
109             log.info("using video encoder %s", name)
110             return name
111 
112     raise RenderError(
113         "No working H.264 encoder found (tried "
114         + ", ".join(n for n in _H264_PREFERENCE if n in have)
115         + "). Install an ffmpeg with libx264 or libopenh264, or set "
116         "SUBPLZ_WEB_RENDER_VIDEO=false."
117     )
118 
119 
120 def extract_cover(book: Path, dest_dir: Path) -> Path | None:
121     """Pull the largest image out of an epub, as a stand-in for cover art.
122 
123     "Largest" beats parsing the OPF metadata for this purpose: the cover is
124     almost always the biggest image, and this still works on the malformed
125     epubs that converted books often are.
126     """
127     if book.suffix.lower() != ".epub":
128         return None
129     try:
130         with zipfile.ZipFile(book) as zf:
131             images = [
132                 info for info in zf.infolist()
133                 if Path(info.filename).suffix.lower() in _IMAGE_SUFFIXES
134             ]
135             if not images:
136                 return None
137             best = max(images, key=lambda i: i.file_size)
138             if best.file_size < 1024:  # a bullet or a rule, not a cover
139                 return None
140             dest = dest_dir / f"cover{Path(best.filename).suffix.lower()}"
141             dest.parent.mkdir(parents=True, exist_ok=True)
142             dest.write_bytes(zf.read(best.filename))
143             return dest
144     except (zipfile.BadZipFile, OSError, KeyError):
145         return None
146 
147 
148 def build_canvas(cover: Path | None, dest: Path) -> Path:
149     """Scale the cover onto a fixed canvas once, ahead of the encode."""
150     w, h = settings.video_width, settings.video_height
151     dest.parent.mkdir(parents=True, exist_ok=True)
152 
153     if cover is not None and cover.exists():
154         cmd = [
155             "ffmpeg", "-hide_banner", "-v", "error", "-y",
156             "-i", str(cover),
157             "-vf",
158             f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
159             f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color=black",
160             "-frames:v", "1",
161             str(dest),
162         ]
163     else:
164         # No cover: a plain dark card still gives YouTube a valid video stream.
165         cmd = [
166             "ffmpeg", "-hide_banner", "-v", "error", "-y",
167             "-f", "lavfi", "-i", f"color=c=0x16161a:s={w}x{h}",
168             "-frames:v", "1",
169             str(dest),
170         ]
171 
172     proc = subprocess.run(cmd, capture_output=True, timeout=300)
173     if proc.returncode != 0 or not dest.exists():
174         err = proc.stderr.decode("utf-8", errors="replace").strip().splitlines()
175         raise RenderError(
176             "could not prepare the cover image: "
177             + (" | ".join(err[-2:]) if err else "no output")
178         )
179     return dest
180 
181 
182 def render_video(
183     audio: Path,
184     canvas: Path,
185     dest: Path,
186     duration: float | None = None,
187 ) -> Path:
188     """Cover image + audio into a clean MP4, carrying no subtitle track.
189 
190     Subtitle-free on purpose. This is the file for YouTube, where captions are
191     attached separately as an .srt: YouTube's handling of an embedded mov_text
192     track is unreliable, and uploading the .srt is the supported route.
193 
194     This is the expensive step - `mux_subtitles` reuses its output instead of
195     encoding a second time.
196     """
197     dest.parent.mkdir(parents=True, exist_ok=True)
198 
199     cmd = [
200         "ffmpeg", "-hide_banner", "-v", "error", "-y",
201         "-loop", "1", "-r", str(settings.video_fps), "-i", str(canvas),
202         "-i", str(audio),
203     ]
204     if duration:
205         cmd += ["-t", f"{duration:.3f}"]
206     else:
207         cmd += ["-shortest"]
208 
209     encoder = resolve_encoder()
210     cmd += [
211         "-map", "0:v", "-map", "1:a",
212         # Carry chapter marks through; harmless where they are ignored.
213         "-map_chapters", "1",
214         "-c:v", encoder,
215         "-pix_fmt", "yuv420p",
216         "-c:a", "aac", "-b:a", "128k",
217         # Lets a player start without reading the whole file first.
218         "-movflags", "+faststart",
219     ]
220     cmd += _ENCODER_FLAGS.get(encoder, [])
221     # -crf is a libx264/libx265 concept; the others have their own rate control.
222     if encoder == "libx264":
223         cmd += ["-crf", str(settings.video_crf)]
224 
225     cmd.append(str(dest))
226 
227     proc = subprocess.run(
228         cmd, capture_output=True, timeout=settings.job_timeout_seconds
229     )
230     if proc.returncode != 0 or not dest.exists():
231         err = proc.stderr.decode("utf-8", errors="replace").strip().splitlines()
232         tail = " | ".join(err[-3:]) if err else "no output"
233         raise RenderError(f"ffmpeg could not build the video: {tail}")
234     return dest
235 
236 
237 def mux_subtitles(video: Path, subtitles: Path, dest: Path) -> Path:
238     """Copy `video` into an MKV carrying `subtitles` as a selectable track.
239 
240     For local playback - MPV, VLC, Jellyfin - where one self-contained file
241     beats juggling a video and a sidecar .srt.
242 
243     MKV rather than MP4 because MKV stores SRT natively, keeping the cue text
244     intact; MP4 must convert to mov_text, which is a lossy reduction. Nothing
245     is re-encoded here: the streams are copied, so this costs seconds however
246     long the book is.
247     """
248     dest.parent.mkdir(parents=True, exist_ok=True)
249     cmd = [
250         "ffmpeg", "-hide_banner", "-v", "error", "-y",
251         "-i", str(video),
252         # Declare the format; ffmpeg does not always sniff an srt correctly.
253         "-f", "srt", "-i", str(subtitles),
254         # Map video and audio explicitly rather than "-map 0": an MP4 carries
255         # its chapters as a bin_data track, and Matroska accepts only audio,
256         # video and subtitle streams - it refuses the file outright otherwise.
257         # Chapters still survive via -map_chapters, being container metadata.
258         "-map", "0:v", "-map", "0:a", "-map", "1:s",
259         "-map_chapters", "0",
260         "-c", "copy", "-c:s", "srt",
261         # Players pick this up automatically instead of needing it turned on.
262         "-disposition:s:0", "default",
263         "-metadata:s:s:0", "title=Aligned subtitles",
264         str(dest),
265     ]
266     proc = subprocess.run(cmd, capture_output=True, timeout=1800)
267     if proc.returncode != 0 or not dest.exists():
268         err = proc.stderr.decode("utf-8", errors="replace").strip().splitlines()
269         tail = " | ".join(err[-3:]) if err else "no output"
270         raise RenderError(f"ffmpeg could not embed the subtitles: {tail}")
271     return dest