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