diff --git a/backend/api/routers/audiobook.py b/backend/api/routers/audiobook.py index df180fd1..520b574e 100644 --- a/backend/api/routers/audiobook.py +++ b/backend/api/routers/audiobook.py @@ -24,12 +24,14 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel from services.audiobook import ( - build_chapter_ffmetadata, - build_concat_list, - build_m4b_cmd, parse_audiobook_script, synthesize_chapter, ) +from services.longform_render import ( + build_concat_list, + build_ffmetadata, + build_render_cmd, +) router = APIRouter() @@ -50,6 +52,12 @@ class AudiobookRequest(BaseModel): text: str default_voice: str | None = None # voice profile id; None = engine default bitrate: str = "128k" + format: str = "m4b" # "m4b" | "mp3" + loudness: str | None = None # None/"off" | "acx" | "podcast" (opt-in) + cover_path: str | None = None # server-side path to a jpg/png cover + # Global tags embedded in the output: {title, author, narrator, year, + # genre, description}. Player-visible (Apple Books / Audible read these). + metadata: dict | None = None def _resolve_voice(profile_id: str | None) -> dict: @@ -201,14 +209,19 @@ async def audiobook_synthesize(req: AudiobookRequest): yield _emit({"type": "assembling"}) meta_path = os.path.join(work, "chapters.ffmeta") with open(meta_path, "w", encoding="utf-8") as f: - f.write(build_chapter_ffmetadata(chapters_meta)) + f.write(build_ffmetadata(chapters_meta, global_meta=req.metadata)) concat_path = os.path.join(work, "concat.txt") with open(concat_path, "w", encoding="utf-8") as f: f.write(build_concat_list(chapter_files)) - out_name = f"audiobook_{job_id}.m4b" + ext = "mp3" if (req.format or "").lower() == "mp3" else "m4b" + out_name = f"audiobook_{job_id}.{ext}" out_path = os.path.join(OUTPUTS_DIR, out_name) await run_ffmpeg( - build_m4b_cmd(ffmpeg, concat_path, meta_path, out_path, bitrate=req.bitrate), + build_render_cmd( + ffmpeg, concat_path, meta_path, out_path, + fmt=ext, bitrate=req.bitrate, + cover_path=req.cover_path, loudness=req.loudness, + ), job_id=job_id, ) diff --git a/backend/services/audiobook.py b/backend/services/audiobook.py index d9d0520c..db93ae8a 100644 --- a/backend/services/audiobook.py +++ b/backend/services/audiobook.py @@ -40,7 +40,6 @@ _HEADING_RE = re.compile(r"^[ \t]*#[ \t]+(\S.*)$", re.MULTILINE) # ``finditer`` (the source of the polynomial-time ReDoS). A voice name never # contains a bracket; the value is stripped in code. _VOICE_RE = re.compile(r"\[voice:([^\]\[]*)\]") -_BITRATE_RE = re.compile(r"^\d{2,3}k$") @dataclass @@ -178,44 +177,23 @@ def synthesize_chapter( return audio, audio.shape[-1] / float(sample_rate) -def _escape_meta(value: str) -> str: - """Escape an FFMETADATA value (``=``, ``;``, ``#``, ``\\``, newline).""" - return re.sub(r"([=;#\\\n])", r"\\\1", value or "") +# ── ffmpeg / metadata builders ────────────────────────────────────────────── +# +# These now live in the shared ``longform_render`` core (Stories + Audiobook +# converge on one mux). The thin wrappers below preserve the original +# audiobook-only call sites/signatures; new callers should use +# ``longform_render`` directly to reach global metadata, cover art, loudness, +# and mp3 output. +from services.longform_render import ( # noqa: E402 + build_concat_list, + build_ffmetadata, + build_render_cmd, +) def build_chapter_ffmetadata(chapters: list[tuple[str, int]]) -> str: - """Build an FFMETADATA1 document with one ``[CHAPTER]`` per (title, ms). - - ``chapters`` is ordered ``(title, duration_ms)`` pairs; START/END are the - cumulative millisecond offsets ffmpeg writes into the m4b chapter table. - """ - lines = [";FFMETADATA1"] - start = 0 - for title, dur_ms in chapters: - end = start + max(0, int(dur_ms)) - lines += [ - "[CHAPTER]", - "TIMEBASE=1/1000", - f"START={start}", - f"END={end}", - f"title={_escape_meta(title)}", - ] - start = end - return "\n".join(lines) + "\n" - - -def build_concat_list(wav_paths: list[str]) -> str: - """Build an ffmpeg concat-demuxer list for the chapter WAVs. - - Each line is ``file ''`` with single quotes escaped the ffmpeg way - (``'`` → ``'\\''``), so paths with spaces/quotes can't break the list or - inject arguments. - """ - lines = [] - for p in wav_paths: - safe = str(p).replace("'", "'\\''") - lines.append(f"file '{safe}'") - return "\n".join(lines) + "\n" + """Backward-compatible alias: chapters-only FFMETADATA (no global tags).""" + return build_ffmetadata(chapters) def build_m4b_cmd( @@ -226,17 +204,8 @@ def build_m4b_cmd( *, bitrate: str = "128k", ) -> list[str]: - """Pure argv for muxing chapter WAVs + FFMETADATA into a faststart m4b. - - Input 0 is the concat-demuxer list of chapter WAVs; input 1 is the - FFMETADATA file (``-map_metadata 1`` pulls the chapter table from it). - """ - if not _BITRATE_RE.match(bitrate or ""): - bitrate = "128k" - return [ - ffmpeg, "-y", "-hide_banner", "-loglevel", "error", - "-f", "concat", "-safe", "0", "-i", str(concat_list_path), - "-i", str(metadata_path), "-map_metadata", "1", - "-c:a", "aac", "-b:a", bitrate, - "-movflags", "+faststart", "-f", "mp4", str(out_path), - ] + """Backward-compatible alias: a chapterized faststart m4b, no cover/loudness.""" + return build_render_cmd( + ffmpeg, concat_list_path, metadata_path, out_path, + fmt="m4b", bitrate=bitrate, + ) diff --git a/backend/services/longform_render.py b/backend/services/longform_render.py new file mode 100644 index 00000000..d157bed4 --- /dev/null +++ b/backend/services/longform_render.py @@ -0,0 +1,201 @@ +"""Shared long-form render core (Stories + Audiobook convergence). + +Both the Audiobook tab and the Stories Editor produce the *same* artifact: a +chapter-marked audio file built from chapter WAVs. This module owns the pure, +engine-agnostic ffmpeg/metadata builders for that mux so neither feature has to +reimplement it: + + * ``build_ffmetadata`` — FFMETADATA1 doc: an optional ``[global]`` tag block + (title / author / narrator / year / genre / description) followed by one + ``[CHAPTER]`` per (title, duration_ms). + * ``build_concat_list`` — ffmpeg concat-demuxer list of chapter WAVs. + * ``build_loudnorm_filter`` — an ``-af loudnorm=…`` string for an ACX / + podcast loudness preset (off by default — opt-in, so the default-behavior + stays platform-identical). + * ``validate_cover_image`` — guard a cover path (type + size) before it + reaches ffmpeg. + * ``build_render_cmd`` — pure argv for the mux: chapter WAVs + FFMETADATA + (+ optional cover art, loudness filter), output as ``m4b`` or ``mp3``. + +Every function here is pure (string/argv in, string/argv out) so it's unit +tested without ffmpeg, torch, or a GPU. The impure ffmpeg run lives in the +caller (the audiobook router today; the stories job tomorrow). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional + +_BITRATE_RE = re.compile(r"^\d{2,3}k$") +_COVER_EXTS = {".jpg", ".jpeg", ".png"} +_COVER_MAX_BYTES = 8 * 1024 * 1024 # 8 MB — a book cover, not a payload + +#: Our metadata field → FFMETADATA tag key. Order is stable for deterministic +#: output (tested). ``author`` maps to ``artist`` and ``narrator`` to +#: ``composer`` — the tags audiobook players (Apple Books, Audible) read for +#: those roles. +_GLOBAL_TAG_KEYS: list[tuple[str, str]] = [ + ("title", "title"), + ("author", "artist"), + ("album", "album"), + ("narrator", "composer"), + ("year", "date"), + ("genre", "genre"), + ("description", "comment"), +] + + +def _escape_meta(value: str) -> str: + """Escape an FFMETADATA value (``=``, ``;``, ``#``, ``\\``, newline).""" + return re.sub(r"([=;#\\\n])", r"\\\1", value or "") + + +# ── Loudness normalization ────────────────────────────────────────────────── + +@dataclass(frozen=True) +class LoudnessPreset: + """A loudnorm target. ``i`` = integrated LUFS, ``tp`` = true-peak ceiling + (dBTP), ``lra`` = loudness range.""" + key: str + i: float + tp: float + lra: float + + +#: ``acx`` targets Audible/ACX submission (≈ -19 LUFS integrated, ≤ -3 dBTP +#: peak — inside ACX's -23…-18 dB RMS / -3 dB peak window). ``podcast`` targets +#: the -16 LUFS streaming norm. +LOUDNESS_PRESETS: dict[str, LoudnessPreset] = { + "acx": LoudnessPreset("acx", -19.0, -3.0, 11.0), + "podcast": LoudnessPreset("podcast", -16.0, -1.5, 11.0), +} + + +def build_loudnorm_filter(preset: Optional[str]) -> Optional[str]: + """Return an ``-af`` loudnorm filter string for ``preset``, or ``None`` for + off / unknown (single-pass; two-pass measure→apply is a runner enhancement). + """ + if not preset: + return None + p = LOUDNESS_PRESETS.get(preset.lower()) + if p is None: # "off", "none", or anything unrecognized → no filter + return None + return f"loudnorm=I={p.i}:TP={p.tp}:LRA={p.lra}" + + +# ── FFMETADATA ────────────────────────────────────────────────────────────── + +def build_ffmetadata( + chapters: Iterable[tuple[str, int]], + global_meta: Optional[dict] = None, +) -> str: + """Build an FFMETADATA1 doc: optional global tags + one ``[CHAPTER]`` per + ``(title, duration_ms)``. START/END are cumulative millisecond offsets. + """ + lines = [";FFMETADATA1"] + if global_meta: + for field_key, meta_key in _GLOBAL_TAG_KEYS: + val = global_meta.get(field_key) + if val is not None and str(val).strip(): + lines.append(f"{meta_key}={_escape_meta(str(val).strip())}") + start = 0 + for title, dur_ms in chapters: + end = start + max(0, int(dur_ms)) + lines += [ + "[CHAPTER]", + "TIMEBASE=1/1000", + f"START={start}", + f"END={end}", + f"title={_escape_meta(title)}", + ] + start = end + return "\n".join(lines) + "\n" + + +def build_concat_list(wav_paths: Iterable[str]) -> str: + """Build an ffmpeg concat-demuxer list. Single quotes in paths are escaped + the ffmpeg way (``'`` → ``'\\''``) so paths can't break the list or inject + arguments.""" + lines = [] + for p in wav_paths: + safe = str(p).replace("'", "'\\''") + lines.append(f"file '{safe}'") + return "\n".join(lines) + "\n" + + +# ── Cover art ─────────────────────────────────────────────────────────────── + +def validate_cover_image(path: Optional[str]) -> bool: + """True if ``path`` is a readable jpg/png within the size cap. Anything + dubious (missing, wrong type, too big, unreadable) → False, and the caller + simply omits the cover rather than failing the render.""" + if not path: + return False + try: + p = Path(path) + return ( + p.is_file() + and p.suffix.lower() in _COVER_EXTS + and 0 < p.stat().st_size <= _COVER_MAX_BYTES + ) + except OSError: + return False + + +# ── Render command ────────────────────────────────────────────────────────── + +def build_render_cmd( + ffmpeg: str, + concat_list_path: str, + metadata_path: str, + out_path: str, + *, + fmt: str = "m4b", + bitrate: str = "128k", + cover_path: Optional[str] = None, + loudness: Optional[str] = None, +) -> list[str]: + """Pure argv for muxing chapter WAVs + FFMETADATA into a tagged, + chapter-marked audio file. + + Inputs: 0 = concat-demuxer list of chapter WAVs, 1 = FFMETADATA (chapters + + global tags), 2 = cover image (only when present + valid). ``fmt`` is + ``m4b`` (AAC in mp4, faststart) or ``mp3`` (libmp3lame). A loudness preset + adds an ``-af loudnorm`` pass; an invalid/oversized cover is silently + dropped (see :func:`validate_cover_image`). + """ + if not _BITRATE_RE.match(bitrate or ""): + bitrate = "128k" + is_mp3 = (fmt or "").lower() == "mp3" + have_cover = validate_cover_image(cover_path) + + cmd = [ + ffmpeg, "-y", "-hide_banner", "-loglevel", "error", + "-f", "concat", "-safe", "0", "-i", str(concat_list_path), + "-i", str(metadata_path), + ] + if have_cover: + cmd += ["-i", str(cover_path)] + + cmd += ["-map", "0:a", "-map_metadata", "1"] + if have_cover: + cmd += ["-map", "2:v", "-disposition:v", "attached_pic"] + + filt = build_loudnorm_filter(loudness) + if filt: + cmd += ["-af", filt] + + if is_mp3: + cmd += ["-c:a", "libmp3lame", "-b:a", bitrate] + if have_cover: + cmd += ["-c:v", "copy", "-id3v2_version", "3"] + cmd += ["-f", "mp3", str(out_path)] + else: # m4b — AAC in an mp4 container + cmd += ["-c:a", "aac", "-b:a", bitrate] + if have_cover: + cmd += ["-c:v", "copy"] + cmd += ["-movflags", "+faststart", "-f", "mp4", str(out_path)] + return cmd diff --git a/docs/specs/2026-06-13-stories-audiobook-maturity.md b/docs/specs/2026-06-13-stories-audiobook-maturity.md new file mode 100644 index 00000000..c0edfb8b --- /dev/null +++ b/docs/specs/2026-06-13-stories-audiobook-maturity.md @@ -0,0 +1,164 @@ +# Stories Editor & Audiobook — Maturity Spec + +> Compiled 2026-06-13. Targets the v0.3.x continuous-to-main line. Grounds every +> work item in the two features' current implementation (file:line refs below). + +## 0. TL;DR — the central decision + +Stories Editor and Audiobook are **two authoring frontends over the same job**: +*chapterized long-form TTS → a chapter-marked audio file*. Today they're built +on divergent stacks, and that divergence is the root cause of most maturity gaps: + +| | Stories Editor | Audiobook | +|---|---|---| +| Render | **Client-side** Web Audio stitch (`decodeAudioData` per chunk, concat in RAM) | **Server-side** GPU pool → ffmpeg `.m4b` | +| State | localStorage only (`omnivoice.app`) | best-effort `job_store`, discarded on exit | +| Strength | multi-character dialogue, per-line voice/speed, auto-cast | chapter markers, SSE progress, scales to long books | +| Ceiling | can't render a 10-hour book (browser RAM, no resume, no loudness norm) | single textarea, no cover/metadata, no preview/retry/resume | + +**Recommendation: converge on one server-side chapterized render core; keep two +distinct authoring frontends.** Stories Editor's "Generate full" compiles its +cast/lines into the same `Chapter[]`/`Span[]` model the Audiobook backend already +uses and submits the same job. This kills the duplication, lifts the browser +scale ceiling off Stories, and gives *both* features resume, loudness +normalization, cover art, and metadata for free. + +Stories keeps its fast **client-side single-line preview** (low latency, no job +overhead). Only the full export moves server-side. + +> **Alternative considered:** keep them fully separate and mature each stack +> independently. Rejected — it doubles the work (two metadata systems, two +> normalization paths, two resume mechanisms) and leaves Stories permanently +> unable to render book-length output. If the owner wants them kept separate, +> only §2 + §3-frontend apply and §1 is dropped. + +--- + +## 1. Shared render core (new) — backend + +**New module `backend/services/longform_render.py`**, generalizing the audiobook +pipeline (which already has the right shape). + +- **Canonical job model** (already exists in `backend/services/audiobook.py:47-86`): + `Project → Chapter(title, spans) → Span(voice_id, text, pause_ms_after)`. + Promote it here; Audiobook and Stories both compile to it. +- **Reuse** `synthesize_chapter` (audiobook.py:140), `build_chapter_ffmetadata` + (:186), `build_concat_list` (:207), `build_m4b_cmd` (:221). Generalize + `build_*_cmd` to take metadata + cover + format. +- **Add capabilities** (each is a discrete, testable pure builder + a wiring step): + 1. **Global metadata** → FFMETADATA `[global]`: `title`, `artist` (author), + `album`, `composer`/`narrator`, `date` (year), `genre`, `comment`. + 2. **Cover art** → ffmpeg `-i cover.jpg -map 2 -disposition:v attached_pic` + (MP4 `COVR`). Validate image (size/type) server-side. + 3. **Loudness normalization** → two-pass ffmpeg `loudnorm` to an **ACX preset** + (target ≈ -19…-21 LUFS integrated, ≤ -3 dB true peak, noise floor < -60 dB). + Toggle + preset (`ACX` / `Podcast -16 LUFS` / `Off`). + 4. **Per-chapter checkpoint / resume**: cache each rendered chapter WAV keyed + by `hash(span_texts + voice_ids + pauses + engine + voice refs)`. On retry, + skip chapters whose hash already has a cached WAV → resume a failed/long job + without re-rendering completed chapters. + 5. **Parallel chapter synth**: submit chapters to the existing `_gpu_pool` + concurrently (bounded by pool size) instead of strictly sequential. + 6. **Output formats**: `m4b` (chaptered AAC, current), `mp3` (chaptered via + ID3 CHAP frames), `per-chapter files` (zip), `stems` (one track per voice — + Stories' existing stems concept, server-side). +- **Job persistence / library**: new alembic table `longform_jobs` + (id, kind=`audiobook`|`story`, title, status, output_path, chapters json, + metadata json, created_at). A finished book/story reappears in Projects/Library + and is re-downloadable. Backward-compatible migration (additive table). + +--- + +## 2. Audiobook tab — maturity (frontend + thin backend) + +Anchors: `frontend/src/pages/AudiobookTab.jsx`, `frontend/src/api/audiobook.ts`, +`backend/api/routers/audiobook.py`, `backend/services/audiobook.py`. + +**P0** +- **Metadata panel**: title, author, narrator, year, genre, description, **cover + image** picker. Pipes to §1.1/§1.2. +- **Per-chapter preview**: render a single chapter (new `POST /audiobook/preview` + reusing `synthesize_chapter`) so users audition before committing the full book. +- **Chapter-level retry**: a failed chapter doesn't kill the job; mark it, let the + user re-run just that chapter (uses §1.4 checkpoints). +- **Resume**: reconnect to / restart an interrupted job, skipping cached chapters. +- **Loudness-norm toggle** (ACX preset default-off; explicit opt-in). + +**P1** +- **Import → auto-chapter**: `.txt` (split on `# H1` / `^Chapter \d+`), **EPUB** + (spine + TOC → chapters; local parse, no network), drag-drop file. +- **Pronunciation lexicon**: per-project word→phoneme/respelling overrides applied + pre-synthesis (shared with Stories). +- **ETA + richer progress** (chapters done / total, elapsed, est. remaining). + +**P2** +- SSML-lite (`[emphasis]`, `[slow]`/`[fast]`, `[spell]`) mapped to engine instruct + + chunk rate. Batch (queue multiple books). + +--- + +## 3. Stories Editor — maturity (frontend + backend) + +Anchors: `frontend/src/components/StoriesEditor.jsx` (749-line monolith), +`frontend/src/store/storiesSlice.ts`, `frontend/src/utils/storyExport.js`, +`frontend/src/utils/parseScript.js`. + +**P0** +- **Move "Generate full" to the shared server-side job** (§1). Add a + `storyToSpans()` compiler: cast + lines + `[voice:]`/`[pause]` markers → + `Chapter[]`/`Span[]`. `storyExport.js` shrinks to **preview-only** (keep the + snappy single-line client playback at `StoriesEditor.jsx:271-322`). +- **Per-line regenerate** (currently only full export exists). +- **Abort/cancel** an in-progress export (today the loop has no abort signal). +- **Wire the emotion/tone field → `instruct`** (`storiesSlice.ts:17` "Phase 3" + stub; tone chips insert text markers but never reach synthesis). + +**P1** +- **Component split**: `CastPanel.jsx` / `StoryLine.jsx` / `LineDrawer.jsx` + (already flagged in `docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md`). +- **Per-line waveform + duration** thumbnail. +- **Chaptered M4B export** with markers (reuse §1; Stories already detects + `# ` chapter lines at `StoriesEditor.jsx:46`). +- **Optional server-persisted projects** (DB) so large casts/long scripts survive + localStorage limits; lazy-migrate existing localStorage projects. + +**P2** +- EPUB/screenplay import polish; richer auto-cast (gender/voice matching). + +--- + +## 4. Cross-cutting + +- **Loudness norm, metadata+cover, resume/checkpoint, parallel synth** all live in + §1 and are consumed by both features — built once. +- **Pronunciation lexicon** shared service (used by Audiobook P1 + Stories). +- **DB**: `longform_jobs` (+ optional `story_projects`) via alembic, additive, + tested upgrade path. Existing `omnivoice_data/` and localStorage untouched. +- **i18n**: new keys under `audiobook.*` and `stories.*`; no hardcoded CJK. +- **Tests**: extend `tests/test_audiobook.py` — loudnorm argv (two-pass shape), + global-metadata + cover ffmeta/argv, `storyToSpans()` compiler, resume-skip + (cached chapter hash → skipped), EPUB→chapter parse. Frontend: metadata panel, + per-line regen, abort. + +## 5. Constraints honored + +- **Cross-platform parity**: ffmpeg `loudnorm` / `COVR` / `aac` work identically on + mac/Win/Linux (ffmpeg already bundled). Loudness toggle ships **off by default** + → no default-behavior divergence. EPUB parse is local. +- **Local-first**: no cloud, no accounts; all rendering/import on-device. +- **Backward-compat data**: alembic additive tables; localStorage projects migrate + lazily, never break. +- **Versioning**: ships as continuous-to-main patches on the v0.3.x line; no RCs, + no new minor unless the owner asks. + +## 6. Suggested PR slicing (order, each independently shippable) + +1. **Shared `longform_render` core** + loudnorm + global-metadata/cover builders + (backend only, wired behind Audiobook). Pure builders + tests first. +2. **Audiobook metadata + cover UI**. +3. **Per-chapter preview + retry + resume** (checkpoints). +4. **Text/EPUB import + auto-chapter**. +5. **Stories "Generate" → shared job**; `storyToSpans()`; per-line regen; emotion→instruct. +6. **Stories component split + per-line waveform**; chaptered M4B. +7. **DB job library** (Projects/Library shows finished books/stories). +8. (P2) SSML-lite, batch, pronunciation lexicon UI. diff --git a/tests/test_longform_render.py b/tests/test_longform_render.py new file mode 100644 index 00000000..f51d2289 --- /dev/null +++ b/tests/test_longform_render.py @@ -0,0 +1,176 @@ +"""Shared long-form render core (Stories + Audiobook convergence). + +Pure builders for the chapterized mux: FFMETADATA (global tags + chapters), +concat list, loudness filter, cover validation, and the ffmpeg render argv. +All unit-testable without ffmpeg/torch/GPU. +""" +from __future__ import annotations + +import pytest + +from services.longform_render import ( + LOUDNESS_PRESETS, + build_concat_list, + build_ffmetadata, + build_loudnorm_filter, + build_render_cmd, + validate_cover_image, +) + + +# ── loudness ──────────────────────────────────────────────────────────────── + +def test_loudnorm_acx_filter(): + f = build_loudnorm_filter("acx") + assert f == "loudnorm=I=-19.0:TP=-3.0:LRA=11.0" + + +def test_loudnorm_podcast_filter(): + assert build_loudnorm_filter("podcast") == "loudnorm=I=-16.0:TP=-1.5:LRA=11.0" + + +def test_loudnorm_case_insensitive(): + assert build_loudnorm_filter("ACX") == build_loudnorm_filter("acx") + + +@pytest.mark.parametrize("val", [None, "", "off", "none", "bogus"]) +def test_loudnorm_off_or_unknown_is_none(val): + assert build_loudnorm_filter(val) is None + + +def test_loudness_presets_within_acx_window(): + # ACX wants integrated near -19 LUFS and a -3 dB peak ceiling. + acx = LOUDNESS_PRESETS["acx"] + assert -23.0 <= acx.i <= -18.0 + assert acx.tp == -3.0 + + +# ── FFMETADATA ────────────────────────────────────────────────────────────── + +def test_ffmetadata_chapters_only_matches_legacy_shape(): + doc = build_ffmetadata([("One", 1000), ("Two", 500)]) + assert doc.startswith(";FFMETADATA1\n") + assert "[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\ntitle=One" in doc + assert "START=1000\nEND=1500\ntitle=Two" in doc + # No global tags when none supplied. + assert "artist=" not in doc + + +def test_ffmetadata_global_tags_mapped_and_ordered(): + doc = build_ffmetadata( + [("Ch", 1000)], + global_meta={ + "title": "My Book", "author": "Ada", "narrator": "Grace", + "year": "2026", "genre": "Sci-Fi", "description": "A tale", + }, + ) + # field → tag mapping (author→artist, narrator→composer, year→date, + # description→comment) and stable order (title before artist). + head = doc.split("[CHAPTER]")[0] + assert head.index("title=My Book") < head.index("artist=Ada") + assert "composer=Grace" in head + assert "date=2026" in head + assert "genre=Sci-Fi" in head + assert "comment=A tale" in head + + +def test_ffmetadata_skips_empty_global_values(): + doc = build_ffmetadata([("Ch", 1)], global_meta={"title": "T", "author": " ", "genre": None}) + head = doc.split("[CHAPTER]")[0] + assert "title=T" in head + assert "artist=" not in head # whitespace-only dropped + assert "genre=" not in head # None dropped + + +def test_ffmetadata_escapes_special_chars(): + doc = build_ffmetadata([("a=b;c#d", 100)], global_meta={"title": "x=y"}) + assert r"title=x\=y" in doc + assert r"title=a\=b\;c\#d" in doc + + +# ── concat list ───────────────────────────────────────────────────────────── + +def test_concat_list_quotes_and_escapes(): + out = build_concat_list(["/a/one.wav", "/weird/it's here.wav"]) + assert "file '/a/one.wav'" in out + assert "file '/weird/it'\\''s here.wav'" in out + + +# ── cover validation ──────────────────────────────────────────────────────── + +def test_cover_valid(tmp_path): + p = tmp_path / "cover.jpg" + p.write_bytes(b"\xff\xd8\xff" + b"x" * 100) + assert validate_cover_image(str(p)) is True + + +def test_cover_rejects_missing_and_bad_type(tmp_path): + assert validate_cover_image(None) is False + assert validate_cover_image(str(tmp_path / "nope.jpg")) is False + txt = tmp_path / "c.txt" + txt.write_bytes(b"hi") + assert validate_cover_image(str(txt)) is False + + +def test_cover_rejects_oversize(tmp_path): + big = tmp_path / "big.png" + big.write_bytes(b"\x89PNG" + b"0" * (8 * 1024 * 1024 + 1)) + assert validate_cover_image(str(big)) is False + + +def test_cover_rejects_empty(tmp_path): + empty = tmp_path / "empty.jpg" + empty.write_bytes(b"") + assert validate_cover_image(str(empty)) is False + + +# ── render command ────────────────────────────────────────────────────────── + +def test_render_cmd_m4b_default(): + cmd = build_render_cmd("ffmpeg", "concat.txt", "ch.ffmeta", "out.m4b") + assert cmd[0] == "ffmpeg" + assert "-f" in cmd and "concat" in cmd + assert cmd[-3:] == ["-f", "mp4", "out.m4b"] + assert "-c:a" in cmd and "aac" in cmd + assert "+faststart" in cmd + assert "-map_metadata" in cmd + # no cover, no loudnorm by default + assert "attached_pic" not in cmd + assert "-af" not in cmd + + +def test_render_cmd_mp3_format(): + cmd = build_render_cmd("ffmpeg", "c.txt", "m.ffmeta", "out.mp3", fmt="mp3") + assert "libmp3lame" in cmd + assert cmd[-3:] == ["-f", "mp3", "out.mp3"] + assert "+faststart" not in cmd + + +def test_render_cmd_bitrate_validation(): + ok = build_render_cmd("ffmpeg", "c", "m", "o", bitrate="192k") + assert "192k" in ok + bad = build_render_cmd("ffmpeg", "c", "m", "o", bitrate="; rm -rf /") + assert "128k" in bad # rejected → default + assert "; rm -rf /" not in bad + + +def test_render_cmd_loudnorm_adds_af(): + cmd = build_render_cmd("ffmpeg", "c", "m", "o", loudness="acx") + assert "-af" in cmd + assert any(a.startswith("loudnorm=") for a in cmd) + + +def test_render_cmd_with_cover(tmp_path): + cover = tmp_path / "cover.jpg" + cover.write_bytes(b"\xff\xd8\xff" + b"x" * 50) + cmd = build_render_cmd("ffmpeg", "c", "m", "o.m4b", cover_path=str(cover)) + # cover becomes input 2, mapped as attached_pic, copied + assert str(cover) in cmd + assert "-map" in cmd and "2:v" in cmd + assert "attached_pic" in cmd + assert "-c:v" in cmd and "copy" in cmd + + +def test_render_cmd_drops_invalid_cover(tmp_path): + cmd = build_render_cmd("ffmpeg", "c", "m", "o.m4b", cover_path=str(tmp_path / "missing.jpg")) + assert "attached_pic" not in cmd # silently dropped, render still proceeds