Files
UmbraTrans/tools/translation/book.py
umbra audio 3e0568b9d1 Generate backend endpoints for language-project workflow
Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text

Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
2026-10-25 11:04:53 -07:00

410 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Load the Deal Hunter controller bundle into a book model.
The bundle is a JSONL manifest produced by UmbraAudio. One utterance is a
sentence or a short paragraph; the original DOCX paragraph boundaries are not
in the bundle. This module therefore reconstructs readable paragraphs with a
documented heuristic and keeps the source utterance indices on every
paragraph so a later audio-alignment step can still map back.
Nothing here calls an LLM.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from dataclasses import asdict, dataclass, field
from pathlib import Path
DEFAULT_BUNDLE = Path("/home/umbra/audio-read/bundles/dh-bundle/manifest.jsonl")
# Chinese chapter titles. Kept here, not inferred, so every render is stable.
# Reviewable in one place; the English title stays on the paragraph.
TITLE_ZH = {
"Sample Track": "音频版序言:声音介绍",
"Opening Track": "开篇说明",
"Copyright": "版权信息",
"Author's Note": "作者的话",
"Salvage": "打捞",
"Family Reunion": "家庭团聚",
"Selling Salvage": "出售打捞物",
"Frustration": "挫败",
"Next Bounty": "下一个赏金",
"Time for a Change": "改变的时刻",
"Trade Hub Vince-4": "贸易枢纽文斯-4",
"Can I Join the Crew": "我能加入船员吗",
"Salvage from Crashed Fighters": "坠毁战机的残骸",
"Where Is the Captain?": "船长在哪里?",
"Turn in Fugitives": "上交逃犯",
"Her New Ship": "她的新船",
"Introducing the new XO": "新任副舰长",
"A New Ship Salvage": "新船打捞",
"The Poker Game Distraction": "扑克牌局掩护",
"Deal Hunter Bounty": "Deal Hunter 的赏金",
"Selecting Bounties": "挑选赏金",
"Shuttle Scans": "穿梭机扫描",
"The Pirate Trap": "海盗陷阱",
"The Vid Interview": "视频采访",
"Hunting Pirates": "追猎海盗",
"The Friendly Pirate": "友好的海盗",
"Aunt Vicky": "维姬姑妈",
"Preparation": "准备",
"The Coronation": "加冕典礼",
"Long Live the King": "国王万岁",
"Let’s Make It Better": "让它变得更好",
"Let's Make It Better": "让它变得更好",
"Closing Track": "后记:感谢收听",
}
MAX_NARRATION_SENTENCES = 3
MAX_NARRATION_WORDS = 58
@dataclass
class Utterance:
index: int
speaker: str
type: str
text: str
@dataclass
class Paragraph:
id: str
speaker: str
type: str
text: str
source_indices: list[int] = field(default_factory=list)
source_ids: list[str] = field(default_factory=list)
@property
def words(self) -> int:
return len(self.text.split())
@dataclass
class Scene:
id: str
index: int
paragraphs: list[Paragraph] = field(default_factory=list)
@dataclass
class Chapter:
id: str
index: int
row: int
title: str
title_zh: str
matter: str
paragraphs: list[Paragraph] = field(default_factory=list)
scenes: list[Scene] = field(default_factory=list)
def scenes_json(self) -> list[dict]:
return [asdict(s) for s in self.scenes]
@dataclass
class Book:
title: str
title_zh: str
author: str
source_language: str
target_language: str
bundle: str
bundle_sha256: str
chapters: list[Chapter] = field(default_factory=list)
def chapter(self, ref: str | int) -> Chapter:
for c in self.chapters:
if str(c.index) == str(ref) or c.id == str(ref):
return c
raise KeyError(f"chapter not found: {ref}")
def to_json(self) -> dict:
return {
"title": self.title,
"title_zh": self.title_zh,
"author": self.author,
"source_language": self.source_language,
"target_language": self.target_language,
"bundle": self.bundle,
"bundle_sha256": self.bundle_sha256,
"chapters": [
{
**{k: v for k, v in asdict(c).items() if k != "scenes"},
"scenes": [asdict(s) for s in c.scenes],
}
for c in self.chapters
],
}
def _sentences(text: str) -> int:
# A conservative sentence count for grouping only. Quotes and ellipses are
# the places where a naive split goes wrong, so count terminators instead.
n = len(re.findall(r"[.!?]+(?:[”’\"']|$|\s)", text))
return max(1, n)
def _classify(index: int, title: str) -> str:
if index < 0:
if title in {"Sample Track", "Opening Track"}:
return "front_audio"
if title == "Copyright":
return "front_copyright"
if title == "Author's Note":
return "front_note"
return "front"
if title == "Closing Track" or index > 26:
return "back"
return "body"
def _clean_title(title: str) -> str:
return title.replace("’", "'").strip()
def _title_zh(title: str) -> str:
key = _clean_title(title)
if key in TITLE_ZH:
return TITLE_ZH[key]
# Unknown title: leave a visible marker rather than invent a translation.
return f"[待译] {title}"
def load_manifest(path: Path = DEFAULT_BUNDLE) -> tuple[list[dict], dict]:
raw = path.read_bytes()
digest = hashlib.sha256(raw).hexdigest()
records = [json.loads(line) for line in raw.decode("utf-8").splitlines() if line.strip()]
return records, {"path": str(path), "sha256": digest}
def build_book(path: Path = DEFAULT_BUNDLE) -> Book:
records, meta = load_manifest(path)
book_rec = next(r for r in records if r.get("kind") == "book")
chapter_recs = sorted(
(r for r in records if r.get("kind") == "chapter"),
key=lambda r: r["chapter_index"],
)
utterances: dict[str, list[dict]] = {}
for r in records:
if r.get("kind") == "utterance":
utterances.setdefault(r["chapter_id"], []).append(r)
for rows in utterances.values():
rows.sort(key=lambda r: r["utterance_index"])
book = Book(
title="Deal Hunter",
title_zh="交易猎人",
author="B. D. Murphy",
source_language="en",
target_language="zh-CN",
bundle=meta["path"],
bundle_sha256=meta["sha256"],
)
for cr in chapter_recs:
index = int(cr["chapter_index"])
title = cr["title"]
matter = _classify(index, title)
rows = utterances.get(cr["id"], [])
paragraphs = _paragraphs(cr["id"], title, matter, rows)
chapter = Chapter(
id=cr["id"],
index=index,
row=int(cr["database_row_id"]),
title=title,
title_zh=_title_zh(title),
matter=matter,
paragraphs=paragraphs,
)
chapter.scenes = _scenes(cr["id"], paragraphs)
book.chapters.append(chapter)
return book
def _paragraphs(chapter_id: str, title: str, matter: str, rows: list[dict]) -> list[Paragraph]:
if not rows:
return []
# The parser leaves the spoken chapter announcement in the first utterance
# of Salvage ("Deal Hunter. Chapter one, Salvage."). It is a production
# cue, not prose, so it must not be translated into the reading edition.
if matter == "body" and rows[0]["text"].strip().startswith("Deal Hunter. Chapter"):
rows = rows[1:]
# The author's-note heading is an utterance, not a paragraph.
if matter == "front_note" and rows and rows[0]["text"].strip().rstrip(".").lower() in {
"author's note",
"authors note",
"author’s note",
}:
rows = rows[1:]
paragraphs: list[Paragraph] = []
scene_no = 0
buf: list[dict] = []
buf_words = 0
buf_sentences = 0
def flush() -> None:
nonlocal buf, buf_words, buf_sentences
if not buf:
return
paragraphs.append(
Paragraph(
id=f"{chapter_id}-p{len(paragraphs):04d}",
speaker=buf[0].get("speaker_name_resolved") or "narrator",
type=buf[0].get("utterance_type") or "narration",
text=" ".join(r["text"].strip() for r in buf),
source_indices=[int(r["utterance_index"]) for r in buf],
source_ids=[r["id"] for r in buf],
)
)
buf = []
buf_words = 0
buf_sentences = 0
for row in rows:
utype = row.get("utterance_type") or "narration"
speaker = row.get("speaker_name_resolved") or "narrator"
text = (row.get("text") or "").strip()
if utype == "scene_break":
flush()
scene_no += 1
paragraphs.append(
Paragraph(
id=f"{chapter_id}-break{scene_no}",
speaker="",
type="scene_break",
text="* * *",
source_indices=[int(row["utterance_index"])],
source_ids=[row["id"]],
)
)
continue
if matter.startswith("front") or matter == "back":
# Front and back matter are line-oriented: a copyright line and an
# author's-note bullet are not the same thing as a prose paragraph.
flush()
paragraphs.append(
Paragraph(
id=f"{chapter_id}-p{len(paragraphs):04d}",
speaker=speaker,
type=utype,
text=text,
source_indices=[int(row["utterance_index"])],
source_ids=[row["id"]],
)
)
continue
if utype == "dialogue":
# Each speech turn is its own paragraph.
flush()
paragraphs.append(
Paragraph(
id=f"{chapter_id}-p{len(paragraphs):04d}",
speaker=speaker,
type=utype,
text=text,
source_indices=[int(row["utterance_index"])],
source_ids=[row["id"]],
)
)
continue
# Narration: group consecutive sentences into a readable paragraph.
if buf and (
buf[0].get("speaker_name_resolved") != speaker
or buf[0].get("utterance_type") != utype
):
flush()
sentences = _sentences(text)
if buf and (buf_sentences + sentences > MAX_NARRATION_SENTENCES or buf_words + len(text.split()) > MAX_NARRATION_WORDS):
flush()
buf.append(row)
buf_words += len(text.split())
buf_sentences += sentences
if buf_sentences >= MAX_NARRATION_SENTENCES or buf_words >= MAX_NARRATION_WORDS:
flush()
flush()
# Drop a trailing scene-break marker.
while paragraphs and paragraphs[-1].type == "scene_break":
paragraphs.pop()
return paragraphs
def _scenes(chapter_id: str, paragraphs: list[Paragraph]) -> list[Scene]:
scenes: list[Scene] = []
current = Scene(id=f"{chapter_id}-s{len(scenes)}", index=len(scenes))
for p in paragraphs:
if p.type == "scene_break":
if current.paragraphs:
scenes.append(current)
current = Scene(id=f"{chapter_id}-s{len(scenes)}", index=len(scenes))
continue
current.paragraphs.append(p)
if current.paragraphs:
scenes.append(current)
return scenes
def save(book: Book, out: Path) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(book.to_json(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def summary(book: Book) -> str:
lines = [
f"Book: {book.title} / {book.title_zh}",
f"Author: {book.author}",
f"Bundle: {book.bundle}",
f"Bundle sha256: {book.bundle_sha256}",
"",
f"{'idx':>4} {'matter':16} {'paras':>6} {'words':>7} title",
]
for c in book.chapters:
words = sum(p.words for p in c.paragraphs if p.type != "scene_break")
lines.append(
f"{c.index:>4} {c.matter:16} {len(c.paragraphs):>6} {words:>7} {c.title} / {c.title_zh}"
)
body = [c for c in book.chapters if c.matter == "body"]
lines += [
"",
f"Front matter chapters: {sum(1 for c in book.chapters if c.matter.startswith('front'))}",
f"Body chapters: {len(body)}",
f"Back matter chapters: {sum(1 for c in book.chapters if c.matter == 'back')}",
f"Paragraphs: {sum(len(c.paragraphs) for c in book.chapters)}",
f"Words: {sum(p.words for c in book.chapters for p in c.paragraphs if p.type != 'scene_break')}",
]
return "\n".join(lines)
def main() -> None:
ap = argparse.ArgumentParser(description="Extract the Deal Hunter bundle into a book model.")
ap.add_argument("--bundle", type=Path, default=DEFAULT_BUNDLE)
ap.add_argument("--out", type=Path, required=True)
args = ap.parse_args()
book = build_book(args.bundle)
save(book, args.out)
print(summary(book))
print(f"\nwrote {args.out}")
if __name__ == "__main__":
main()