feat(magazine): one-click draw tool, table regions, batch approve, MP3 export, remove-all-boxes
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

Bundles this session's magazine review work: a single "Draw region" tool
replacing six preset-kind buttons; "Table" as a real, non-narrated region
type; uniform auto-OCR for every text-like drawn/re-read region with a
Tesseract fallback when the vision OCR backend is unreachable; a thumbnail
grid view and inline title editing for the article list; batch
approve/reject; standalone MP3 export with embedded title/author/album,
lyrics and cover art (adds mutagen); "Remove all boxes" for starting a
tangled page from scratch; Approve advancing straight to the next article;
a merged, sticky review toolbar; and a fix for stale image previews after
resizing a region's box.

Also fixes several character-portrait rendering biases confirmed live
across this session's DSA audiobook cast (routes/conversation.py): modern
or bald/receding hair regardless of established coverage, armored women
rendering masculinized, duplicated/malformed weapons, a ranged weapon
rendering as a firearm due to a German compound-word regex miss, an
established dwarf rendered with human proportions, and occasional extra
hands or lost non-human species in scene panels.

See CHANGELOG.md [1.43.0] for the full list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-22 20:14:11 +02:00
parent cddc3c226d
commit 94331320c0
9 changed files with 785 additions and 107 deletions

View File

@ -7,6 +7,28 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
## [1.43.0] — 2026-09-22
### Added
- **Magazine: one "Draw region" tool instead of six.** Draw text, Draw image, Headline, Page number, Source/footer and Advertisement were six separate buttons that each pre-selected a region kind before you'd even drawn a box. There is now a single Draw region tool; the panel that appears after you draw the rectangle has a Region type dropdown covering every kind (Paragraph, Headline, Subheading, Standfirst, Author, Caption, Pull quote, Sidenote, Page number, Source/footer, Advertisement, Text in picture, OCR noise, Duplicate, Table, Image) — pick what it is after you've seen the shape, not before.
- **Magazine: "Table" is a real region type.** Marking a region as a table keeps its OCR'd text visible for reference but excludes it from narration, the same as other non-body furniture — no more a dense grid of numbers being read aloud as a flat word stream.
- **Magazine: every text-like drawn or re-read region auto-OCRs when left blank.** Previously only a handful of preset kinds triggered automatic reading; now the rule is uniform — every text-like kind reads itself in, and only Advertisement and Image are skipped (nothing to transcribe in either).
- **Magazine: thumbnail grid view for the article list**, toggleable alongside the existing list view, remembered per browser.
- **Magazine: batch approve/reject.** Select several articles at once and approve or reject them in one action — accepts each exactly as auto-detected, for the pieces that already look right; a messy one still needs individual Review.
- **Magazine: inline title editing.** Click an article's title, in either view, and type — fixes a mangled OCR title ("Sbimmb das? c'b prüFb") without opening Review.
- **Magazine: standalone MP3 export.** Render any approved article to a single MP3 tagged with title, author, album (the magazine's title), its own text as embedded lyrics, and its page image as cover art — for a phone's own music/podcast player or a car head unit, not just this app. "Generate all audio" renders every approved article in one background job; per-article downloads plus a "Download all" ZIP.
- **Magazine: "Remove all boxes."** Deletes every detected region on the current page in one action, with confirmation and Undo, for when the auto-detected layout is too tangled to fix box by box and it's faster to start the page from nothing.
- **Magazine: Approve moves straight to the next article** for review, instead of dropping back to the list — reviewing a long queue no longer means re-opening each one by hand.
### Fixed
- **Magazine: resizing an image region's box didn't visibly update the crop.** The server already re-crops the file correctly, but writes it under the same filename every time, so the browser kept serving its cached copy of the old crop. The preview now busts the cache on the region's own bbox, which changes exactly when the crop does.
- **Magazine: a region drawn or re-read while the vision OCR backend was unreachable always came back "no text recognised."** Confirmed live: the configured vision OCR endpoints were refusing connections (a GPU memory conflict on the host). Both the new-region and re-read paths now fall back to the local Tesseract engine instead of giving up outright.
- **Magazine: the review toolbar no longer fits or stays put on a long page.** Its two page steppers (one bounded to the current article's own pages, one to the whole magazine) almost always agreed and just doubled the toolbar's width; merged into one. The toolbar is now sticky so it stays visible while scrolling a long page's regions.
- **Characters: several recurring portrait-rendering biases fixed**, each confirmed live across this session's cast for the DSA audiobook series: hair defaulting to a modern barbershop cut, or outright bald/receding, regardless of what the sheet said about coverage; an armored woman rendering with a shaved head and masculinized features; a duplicated or malformed weapon (a second sword shown alongside the drawn one, a two-handed Zweihänder rendered with two separate hilts); a crossbow or bow rendering as a modern firearm, traced to the ranged-weapon detection regex missing German compound words like "Armbrustbolzen"; an established dwarf still rendered with tall human body proportions; and a scene panel occasionally adding an extra hand or losing an established non-human species.
### Changed
- Added `mutagen` to `requirements.txt` for the new MP3 export's ID3 tagging (title/artist/album, embedded lyrics, cover art).
## [1.42.4] — 2026-09-22 ## [1.42.4] — 2026-09-22
### Fixed ### Fixed

View File

@ -1 +1 @@
1.42.4 1.43.0

View File

@ -2657,24 +2657,48 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int,
manual_type, region_label = { manual_type, region_label = {
"text": (BLOCK_BODY, None), "text": (BLOCK_BODY, None),
"body": (BLOCK_BODY, None),
"title": (BLOCK_TITLE, None),
"heading": (BLOCK_HEADING, None), "heading": (BLOCK_HEADING, None),
"deck": (BLOCK_DECK, None),
"byline": (BLOCK_BYLINE, None),
"caption": (BLOCK_CAPTION, None),
"pullquote": (BLOCK_PULLQUOTE, None),
"sidebar": (BLOCK_SIDEBAR, None),
"page_number": (BLOCK_FURNITURE, "page_number"), "page_number": (BLOCK_FURNITURE, "page_number"),
"source": (BLOCK_FURNITURE, "source"), "source": (BLOCK_FURNITURE, "source"),
"in_picture": (BLOCK_FURNITURE, "in_picture"),
"ocr_noise": (BLOCK_FURNITURE, "ocr_noise"),
"duplicate": (BLOCK_FURNITURE, "duplicate"),
"table": (BLOCK_TABLE, None),
"ad": (BLOCK_AD, None), "ad": (BLOCK_AD, None),
}.get(kind, (None, None)) }.get(kind, (None, None))
if manual_type is None: if manual_type is None:
raise ValueError("Unsupported manual region kind") raise ValueError("Unsupported manual region kind")
final_text = text.strip() final_text = text.strip()
if not final_text and vision_ocr_url and vision_ocr_model: if not final_text:
crop_path = doc_dir / "pages" / f".manual-crop-{page_no}.png" crop_path = doc_dir / "pages" / f".manual-crop-{page_no}.png"
try: try:
_crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path) _crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path)
# Same two-step read as extract_region_text (used by "read again"
# on an existing region): vision OCR first, but a region drawn
# while that backend is unreachable -- confirmed live, both the
# primary and fallback vision endpoints were refusing connections
# -- must not always come back empty when the local Tesseract
# pass could have read it instead.
if vision_ocr_url and vision_ocr_model:
try:
engines = [(vision_ocr_url, vision_ocr_model), engines = [(vision_ocr_url, vision_ocr_model),
(vision_ocr_fallback_url, vision_ocr_fallback_model)] (vision_ocr_fallback_url, vision_ocr_fallback_model)]
regions = vision_ocr_page_chain(crop_path, engines, timeout=timeout) regions = vision_ocr_page_chain(crop_path, engines, timeout=timeout)
if regions: if regions:
final_text = " ".join(r["text"] for r in regions).strip() final_text = " ".join(r["text"] for r in regions if r.get("text")).strip()
except Exception: # noqa: BLE001 - fall through to local OCR
logging.getLogger(__name__).warning(
"vision OCR unreachable for a manually drawn region; falling back to local OCR")
if not final_text:
final_text = " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip()
finally: finally:
crop_path.unlink(missing_ok=True) crop_path.unlink(missing_ok=True)
if not final_text: if not final_text:
@ -2724,10 +2748,14 @@ def extract_region_text(doc_dir: Path, layout: dict[str, Any], page_no: int,
_crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path) _crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path)
regions = None regions = None
if vision_ocr_url and vision_ocr_model: if vision_ocr_url and vision_ocr_model:
try:
regions = vision_ocr_page_chain(crop_path, [ regions = vision_ocr_page_chain(crop_path, [
(vision_ocr_url, vision_ocr_model), (vision_ocr_url, vision_ocr_model),
(vision_ocr_fallback_url, vision_ocr_fallback_model), (vision_ocr_fallback_url, vision_ocr_fallback_model),
], timeout=timeout) ], timeout=timeout)
except Exception: # noqa: BLE001 - fall through to local OCR
logging.getLogger(__name__).warning(
"vision OCR unreachable for a re-read region; falling back to local OCR")
if regions: if regions:
return " ".join(r.get("text", "") for r in regions if r.get("text")).strip() return " ".join(r.get("text", "") for r in regions if r.get("text")).strip()
return " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip() return " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip()
@ -2990,6 +3018,7 @@ REGION_TYPES: dict[str, tuple[str, str]] = {
"in_picture": (BLOCK_FURNITURE, "in_picture"), "in_picture": (BLOCK_FURNITURE, "in_picture"),
"ocr_noise": (BLOCK_FURNITURE, "ocr_noise"), "ocr_noise": (BLOCK_FURNITURE, "ocr_noise"),
"duplicate": (BLOCK_FURNITURE, "duplicate"), "duplicate": (BLOCK_FURNITURE, "duplicate"),
"table": (BLOCK_TABLE, ""),
"image": ("", ""), "image": ("", ""),
} }
@ -3290,3 +3319,77 @@ def article_text(layout: dict[str, Any], article: dict[str, Any],
}) })
blocks.sort(key=lambda b: (b["page"], b["reading_order"])) blocks.sort(key=lambda b: (b["page"], b["reading_order"]))
return blocks return blocks
# ── Standalone audio (Stage E, optional) ─────────────────────────────────────
# The in-browser reader synthesises sentence-by-sentence into memory and never
# writes a file -- fine for reading inside this app, useless for a car head
# unit or a phone's own podcast/music player. This renders one article down
# to a single tagged MP3 a person can actually take with them.
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„\"'‘])")
def split_sentences_for_tts(text: str) -> list[str]:
return [p.strip() for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
def synthesize_article_audio(doc_dir: Path, article: dict[str, Any], layout: dict[str, Any],
voice: str, settings: dict[str, Any], *,
album: str = "", cover_bytes: bytes | None = None,
cover_mime: str = "image/png", progress_cb=None) -> Path:
"""Render one article to a single MP3, tagged with title/artist/album,
its own text as embedded lyrics, and a cover image.
progress_cb(done, total), if given, is called after each sentence.
"""
from pydub import AudioSegment
import io as _io
from core.tts_helpers import _tts_request_audio
blocks = article_text(layout, article)
full_text = " ".join(b["text"] for b in blocks if (b.get("text") or "").strip()).strip()
if not full_text:
raise ValueError("Article has no narratable text -- approve it with at least one block ticked")
sentences = split_sentences_for_tts(full_text)
total = len(sentences)
combined = AudioSegment.empty()
for i, sentence in enumerate(sentences):
audio_bytes, media_type = _tts_request_audio(sentence, voice, settings)
fmt = "wav" if audio_bytes[:4] == b"RIFF" or "wav" in media_type.lower() else None
combined += AudioSegment.from_file(_io.BytesIO(audio_bytes), format=fmt)
if progress_cb:
progress_cb(i + 1, total)
audio_dir = doc_dir / "audio"
audio_dir.mkdir(parents=True, exist_ok=True)
out_path = audio_dir / f"{article['id']}.mp3"
combined.export(out_path, format="mp3", bitrate="128k")
tag_article_mp3(out_path, article, full_text, album=album,
cover_bytes=cover_bytes, cover_mime=cover_mime)
return out_path
def tag_article_mp3(path: Path, article: dict[str, Any], lyrics: str, *,
album: str = "", cover_bytes: bytes | None = None,
cover_mime: str = "image/png") -> None:
from mutagen.id3 import ID3, ID3NoHeaderError, TIT2, TPE1, TALB, USLT, APIC
try:
tags = ID3(path)
except ID3NoHeaderError:
tags = ID3()
title = (article.get("title") or "Untitled").strip()
author = (article.get("author") or "").strip()
tags.setall("TIT2", [TIT2(encoding=3, text=title)])
tags.setall("TPE1", [TPE1(encoding=3, text=author or album or "Unknown")])
if album:
tags.setall("TALB", [TALB(encoding=3, text=album)])
# 20000 chars matches the review/edit text-field limit used elsewhere for
# article text -- long enough for any real article, short enough that a
# malformed layout cannot balloon the tag.
tags.setall("USLT", [USLT(encoding=3, lang="deu", desc="", text=lyrics[:20000])])
if cover_bytes:
tags.setall("APIC", [APIC(encoding=3, mime=cover_mime, type=3, desc="Cover", data=cover_bytes)])
tags.save(path, v2_version=3)

View File

@ -9,3 +9,4 @@ requests>=2.31.0
Pillow>=10.3.0 Pillow>=10.3.0
yt-dlp>=2024.3.10 yt-dlp>=2024.3.10
pymupdf>=1.24.0 pymupdf>=1.24.0
mutagen>=1.47.0

View File

@ -1963,9 +1963,13 @@ def _character_is_leather_only_armor(sheet: dict) -> bool:
_COMBAT_GEAR_RE = re.compile( _COMBAT_GEAR_RE = re.compile(
# No trailing \b on the German terms: they compound with a suffix and no
# word break ("Armbrustbolzen", "Kriegsaxt", "Bogenschütze") — the same
# miss that let Glombo Brohm's crossbow render as a rifle (see
# _RANGED_WEAPON_RE above).
r"\b(r[üu]stung|panzer|harnisch|kettenhemd|schild|schwert|dolch|messer|speer|lanze|" r"\b(r[üu]stung|panzer|harnisch|kettenhemd|schild|schwert|dolch|messer|speer|lanze|"
r"armbrust|bogen|axt|streitaxt|keule|hellebarde|" r"armbrust|bogen|axt|streitaxt|keule|hellebarde|"
r"armor|armour|shield|sword|dagger|blade|spear|lance|crossbow|bow|axe|mace|halberd)\b", r"armor\b|armour\b|shield\b|sword\b|dagger\b|blade\b|spear\b|lance\b|crossbow\b|bow\b|axe\b|mace\b|halberd\b)",
re.IGNORECASE, re.IGNORECASE,
) )
@ -1989,6 +1993,36 @@ def _character_has_established_combat_gear(sheet: dict) -> bool:
return bool(_COMBAT_GEAR_RE.search(established)) return bool(_COMBAT_GEAR_RE.search(established))
# No trailing \b on armbrust/bogen: German compounds attach a suffix directly
# with no word break ("Armbrustbolzen", "Bogenschütze") — confirmed live on
# Glombo Brohm, whose "Köcher... für Armbrustbolzen" clothing text never
# matched the old \barmbrust\b, so no ranged-weapon shape lock reached the
# render and his crossbow came out rifle-shaped.
_RANGED_WEAPON_RE = re.compile(r"\b(crossbow|longbow|shortbow|bow\b|armbrust|bogen)", re.IGNORECASE)
def _character_ranged_weapon_lock(sheet: dict) -> str:
"""Positive shape lock for an established bow/crossbow.
Without this, an action scene where the character "aims"/"shoots" fell
back on the base model's default for that verb — a modern firearm —
confirmed live on Kolon, whose established "schwere Armbrust" rendered as
a rifle in the story-moment panel even though the isolated prop shot
(which already has this same silhouette lock, see _prop_render_description)
came out correctly. Reuses that same wording so the two panels agree.
"""
costume = _visual_prompt_value(sheet.get("visual_costume")).strip()
established = " ".join(filter(None, (
costume or _visual_prompt_value(sheet.get("clothing")),
", ".join(_character_prop_item_list(sheet)),
)))
match = _RANGED_WEAPON_RE.search(established)
if not match:
return ""
shape = _prop_render_description(match.group(0))
return f"Any ranged weapon shown is {shape}, drawn and aimed by hand exactly like that object. "
_EXPLICIT_AGE_RE = re.compile( _EXPLICIT_AGE_RE = re.compile(
r"\b(\d{1,2})[\s-]*(?:jahre|jährig|j[äa]hrige[rns]?|years?[\s-]*old|y\.?o\.?)\b", re.IGNORECASE, r"\b(\d{1,2})[\s-]*(?:jahre|jährig|j[äa]hrige[rns]?|years?[\s-]*old|y\.?o\.?)\b", re.IGNORECASE,
) )
@ -2068,7 +2102,31 @@ _AGE_BODY_LOCK = {
} }
_DWARF_SPECIES_RE = re.compile(r"zwerg|dwarf", re.IGNORECASE)
def _character_is_dwarf(sheet: dict) -> bool:
species = _visual_prompt_value(sheet.get("race_species")).strip()
return bool(_DWARF_SPECIES_RE.search(species))
# An established dwarf (race_species "Erzzwerg") still rendered with the tall,
# long-legged adult proportions below — confirmed live on Himgi and Kolon,
# both explicitly written as dwarf-built but rendered as ordinary tall men.
# _AGE_BODY_LOCK's "adult" entry exists specifically to stop an unrelated
# short/stubby rendering bug, which fights a genuine dwarf body in the
# opposite direction. Positive-only per the same reasoning as that entry.
_DWARF_BODY_LOCK = (
"EXACTLY ONE ADULT DWARF.",
"adult dwarf",
"Natural adult dwarf body proportions: roughly five and a half head-heights tall, a broad, dense, "
"muscular build, a wide chest, short strong legs, and a thick, full beard. ",
)
def _age_body_lock(sheet: dict) -> tuple[str, str, str]: def _age_body_lock(sheet: dict) -> tuple[str, str, str]:
if _character_is_dwarf(sheet) and _character_age_category(sheet) == "adult":
return _DWARF_BODY_LOCK
return _AGE_BODY_LOCK[_character_age_category(sheet)] return _AGE_BODY_LOCK[_character_age_category(sheet)]
@ -2098,7 +2156,9 @@ def _period_lock(book: str, book_profile: dict, sheet: dict) -> str:
headwear = ( headwear = (
"Use only the explicitly established period headwear and reproduce it unchanged in every view." "Use only the explicitly established period headwear and reproduce it unchanged in every view."
if _character_has_established_headwear(sheet) else if _character_has_established_headwear(sheet) else
"The scalp, crown, hairline, ears, and complete natural hairstyle are visibly exposed and unobstructed." "The scalp, crown, hairline, ears, and a full, thick head of hair reaching at least to the ears or collar "
"are visibly exposed and unobstructed, grown out and trimmed by hand with a knife or shears rather than a "
"modern barbershop cut or a bald/receding head."
) )
leather_only = _character_is_leather_only_armor(sheet) leather_only = _character_is_leather_only_armor(sheet)
materials = ( materials = (
@ -2134,8 +2194,8 @@ def _character_prop_item_list(sheet: dict) -> list[str]:
excluded = re.compile( excluded = re.compile(
r"\b(location|room|place|viewpoint|observation point|residence|stairs?|tower|" r"\b(location|room|place|viewpoint|observation point|residence|stairs?|tower|"
r"post|outpost|station|ort|raum|platz|posten|beobachtungspunkt|beobachtungsposten|wendeltreppe|wohnort|turm|" r"post|outpost|station|ort|raum|platz|posten|beobachtungspunkt|beobachtungsposten|wendeltreppe|wohnort|turm|"
r"person|people|child|baby|infant|companion|horse|battlehorse|pony|animal|creature|" r"person|people|child|baby|infant|companion|horse|battlehorse|pony|animal|creature|hair|wig|"
r"kind|baby|s[äa]ugling|begleiter|pferd|schlachtro(?:ss|ß)|pony|tier|kreatur|" r"kind|baby|s[äa]ugling|begleiter|pferd|schlachtro(?:ss|ß)|pony|tier|kreatur|haar|per[üu]cke|"
r"hat|headwear|hood|cap|helmet|veil|cloak|coat|shirt|robe|armor set|armour set|" r"hat|headwear|hood|cap|helmet|veil|cloak|coat|shirt|robe|armor set|armour set|"
r"clothing|riding clothes|forest clothes|hut|m[üu]tze|kapuze|haube|helm|schleier|umhang|mantel|hemd|waldkleidung|reiterkleidung|robe|r[üu]stung|panzer|" r"clothing|riding clothes|forest clothes|hut|m[üu]tze|kapuze|haube|helm|schleier|umhang|mantel|hemd|waldkleidung|reiterkleidung|robe|r[üu]stung|panzer|"
# Vague plural/collective nouns describe a CATEGORY, not one # Vague plural/collective nouns describe a CATEGORY, not one
@ -2429,6 +2489,24 @@ def _character_identity_phrase(name: str, sheet: dict) -> str:
return ", ".join(parts) return ", ".join(parts)
# established costume text is restated verbatim in CLOTHING LOCK below, so a
# loanword the book itself uses for a military-adjacent role (Offizier,
# Uniform, Korporal, General) reaches the render as-is and outweighs the
# generic period-lock wording — confirmed live on Uriens (ex-corporal) and
# Oberst Alrik von Blautann (title "Oberst"), both of whom rendered in
# 20th-century-style wool uniforms with peaked caps. This is a backstop for
# characters whose sheet text hasn't been hand-corrected yet; the real fix is
# still to write concrete period costume into the sheet.
_ANACHRONISTIC_COSTUME_WORD_RE = re.compile(
r"\boffiziers?(?:uniform)?\b|\buniform\b|\bkorporal\b|\bkorporals\b",
re.IGNORECASE,
)
def _sanitize_anachronistic_costume_words(text: str) -> str:
return _ANACHRONISTIC_COSTUME_WORD_RE.sub("commander's dress", text)
def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
book_profile: dict, visual_style: str, book_profile: dict, visual_style: str,
saved_profile: str, saved_reference: str, saved_profile: str, saved_reference: str,
@ -2526,7 +2604,9 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
# a civilian with no armor established, repeating the actual clothing # a civilian with no armor established, repeating the actual clothing
# words here (not just pointing at them) gives that description a # words here (not just pointing at them) gives that description a
# second, closer mention instead of relying on the Subject clause alone. # second, closer mention instead of relying on the Subject clause alone.
established_costume = _established_visual_value(sheet, "visual_costume", "clothing", 320, strip_injury=True) established_costume = _sanitize_anachronistic_costume_words(
_established_visual_value(sheet, "visual_costume", "clothing", 320, strip_injury=True)
)
clothing_lock = ( clothing_lock = (
f"CLOTHING LOCK: this character's complete outfit in every view is exactly {established_costume} — " f"CLOTHING LOCK: this character's complete outfit in every view is exactly {established_costume} — "
"its own established materials, and nothing more elaborate. " "its own established materials, and nothing more elaborate. "
@ -2557,14 +2637,50 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
# approach, aimed at the armor's surface condition instead of the skin. # approach, aimed at the armor's surface condition instead of the skin.
# A sword was rendered shouldered and aimed forward like a firearm (confirmed live on # A sword was rendered shouldered and aimed forward like a firearm (confirmed live on
# Karyla's scene panel). Positive-only wording for how period arms actually sit in the hand. # Karyla's scene panel). Positive-only wording for how period arms actually sit in the hand.
# A single sword rendered both drawn in the hand and sheathed at the belt
# in the same figure (confirmed live on Lysandra and Oberst Alrik) — the
# existing "no duplicate gear" wording lives only in the prop-panel
# constraints, never in the render prompt that actually reaches the
# figure itself. Positive-only per the reasoning above: state the single
# weapon as a fact of the figure rather than warning against a second one.
weapon_count_lock = (
"This character carries exactly one bladed or hafted weapon, worn or held in a single place. "
) if _character_has_established_combat_gear(sheet) else ""
weapon_handling_lock = ( weapon_handling_lock = (
"Any blade or hafted weapon is held as it was truly used: gripped low by its hilt or haft with a relaxed " "Any blade or hafted weapon is held as it was truly used: gripped low by its hilt or haft with a relaxed "
"wrist, resting against the shoulder, hanging at the side, or sheathed at the belt. " "wrist, resting against the shoulder, hanging at the side, or sheathed at the belt. Each weapon is one "
"single complete object with exactly one hilt and one blade or head, correctly proportioned and "
"physically attached. "
) if _character_has_established_combat_gear(sheet) else "" ) if _character_has_established_combat_gear(sheet) else ""
ranged_weapon_lock = _character_ranged_weapon_lock(sheet)
gear_condition_lock = ( gear_condition_lock = (
"Any worn armor or equipment is clean, freshly polished, and well-kept, showing only ordinary wear from " "Any worn armor or equipment is clean, freshly polished, and well-kept, showing only ordinary wear from "
"daily use. " "daily use. "
) )
# Hair kept coming out precision-tapered and blow-dried straight out of a
# modern barbershop (confirmed live on Darrag, Oberst Alrik, and Eolan),
# and in other cases outright bald or balding (confirmed live on Odalbert)
# — nothing told the model this world has no salons, clippers, or razors,
# and nothing ever stated hair COVERAGE, only its texture, so the model's
# own "gritty medieval warrior" bias defaulted to a shaved or receding
# head. Positive-only per the reasoning above: state the full coverage
# and hand-cut texture actually wanted rather than naming the modern or
# bald look to avoid.
hair_texture_lock = (
"A full, thick head of hair covering the entire scalp, reaching at least to the ears or collar, grown out "
"and trimmed by hand with a knife or shears, natural uneven ends and texture, simply combed, tied back, or "
"left loose, with a lived-in, handmade, slightly imperfect look. "
) if bareheaded else ""
# A woman in armor kept rendering with a shaved or buzzed head and a
# masculinized face (confirmed live on Misira and Baronin Ira von
# Seewiesen) — the base model's "female knight" bias reads combat gear as
# a stronger cue than the stated gender. Positive-only: state the long
# hair and feminine features as a fact of this figure, same reasoning as
# every other lock above.
female_presentation_lock = (
"Long hair reaching past the shoulders, worn loose, braided, or tied back, and clearly feminine facial "
"features and build, exactly as a woman looks wearing this armor. "
) if bareheaded and _visual_prompt_value(sheet.get("gender")).strip().lower() in {"female", "weiblich"} and _character_has_established_combat_gear(sheet) else ""
period = ( period = (
f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. high-fantasy medieval costume design (The Dark Eye), roughly 900-1300 AD: homespun wool and linen tunics, long belted robes, leather belts and jerkins, and simple stitched-leather boots only: " f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. high-fantasy medieval costume design (The Dark Eye), roughly 900-1300 AD: homespun wool and linen tunics, long belted robes, leather belts and jerkins, and simple stitched-leather boots only: "
f"{period_materials}. The Subject alone defines occupation-specific costume elements. Every visible element is pre-industrial and period-authentic. " f"{period_materials}. The Subject alone defines occupation-specific costume elements. Every visible element is pre-industrial and period-authentic. "
@ -2572,8 +2688,12 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
f"{authority_lock}" f"{authority_lock}"
f"{baseline_lock}" f"{baseline_lock}"
f"{gear_condition_lock}" f"{gear_condition_lock}"
f"{weapon_count_lock}"
f"{weapon_handling_lock}" f"{weapon_handling_lock}"
f"{ranged_weapon_lock}"
f"{clothing_lock}" f"{clothing_lock}"
f"{hair_texture_lock}"
f"{female_presentation_lock}"
f"{no_headwear}" f"{no_headwear}"
) )
if visual_style == "concept": if visual_style == "concept":
@ -2616,9 +2736,12 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
f"This character's own costume cut and colours: {_character_design_variation(name, sheet)}." f"This character's own costume cut and colours: {_character_design_variation(name, sheet)}."
if _character_has_established_combat_gear(sheet) else "", if _character_has_established_combat_gear(sheet) else "",
"Bare head, the whole hairstyle visible." if bareheaded else "Only the established headwear, the same in every view.", "Bare head, the whole hairstyle visible." if bareheaded else "Only the established headwear, the same in every view.",
hair_texture_lock.strip(),
f"{ear_shape.capitalize()}." if ear_shape.startswith("pointed") else "", f"{ear_shape.capitalize()}." if ear_shape.startswith("pointed") else "",
proportion_lock.strip() if identical_noun != "adult person" else "", proportion_lock.strip() if identical_noun != "adult person" else "",
"Clean healthy skin and clean, well-kept clothes.", "Clean healthy skin and clean, well-kept clothes.",
weapon_count_lock.strip(),
female_presentation_lock.strip(),
))) )))
turnaround_style = ("Photorealistic, medieval high-fantasy costume in the world of The Dark Eye, hand-made materials." turnaround_style = ("Photorealistic, medieval high-fantasy costume in the world of The Dark Eye, hand-made materials."
if visual_style != "concept" else if visual_style != "concept" else
@ -2654,7 +2777,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
f"EXACT CHARACTER: {sheet_subject}" + (f"; exactly as in the portrait: {portrait_look}" if portrait_look else "") + ". " f"EXACT CHARACTER: {sheet_subject}" + (f"; exactly as in the portrait: {portrait_look}" if portrait_look else "") + ". "
f"BOOK-SOURCE EVIDENCE: {scene_evidence}. " f"BOOK-SOURCE EVIDENCE: {scene_evidence}. "
f"FOCAL PERSON: {_character_identity_phrase(name, sheet)}, exactly as described above, the single clear subject in the foreground. " f"FOCAL PERSON: {_character_identity_phrase(name, sheet)}, exactly as described above, the single clear subject in the foreground. "
"Show the character naturally acting, speaking, working, commanding, using magic, or fighting exactly as the evidence supports. The named character appears exactly once as the focal actor. Preserve the stated face, age, species, build, hair, costume, materials, and period. Use only verified equipment needed by this moment. When the evidence requires supporting figures, give them clearly different faces and simpler clothing while keeping them secondary. " "Show the character naturally acting, speaking, working, commanding, using magic, or fighting exactly as the evidence supports. The named character appears exactly once as the focal actor, with exactly one head and exactly two hands, each holding at most one item, and the same species, skin, and facial features as the turnaround sheet — never rendered as an unmarked ordinary human when a non-human species is established. Preserve the stated face, age, species, build, hair, costume, materials, and period. Every held or worn weapon is one single complete object with exactly one hilt and one blade or head, correctly proportioned and physically attached, never a duplicated or fused shape. Use only verified equipment needed by this moment. When the evidence requires supporting figures, give them clearly different faces and simpler clothing while keeping them secondary. "
f"{period} {sheet_style} Use a believable location from the book, restrained cinematic staging, and complete uncropped head and hands. Keep all surfaces unlettered and free of graphic overlays. Output 768x512." f"{period} {sheet_style} Use a believable location from the book, restrained cinematic staging, and complete uncropped head and hands. Keep all surfaces unlettered and free of graphic overlays. Output 768x512."
) )
prop_period = ( prop_period = (

View File

@ -20,17 +20,19 @@ import asyncio
import base64 import base64
import html import html
import hashlib import hashlib
import io
import json import json
import logging import logging
import re import re
import statistics import statistics
import time import time
import uuid import uuid
import zipfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, Response
from core import magazine as mag from core import magazine as mag
from core.config import _load_settings from core.config import _load_settings
@ -462,6 +464,7 @@ async def magazine_articles(doc_id: str):
# generated, instead of a 404 on every article open. # generated, instead of a 404 on every article open.
for a in articles: for a in articles:
a["has_podcast"] = (d / "podcast" / f"{a['id']}.json").is_file() a["has_podcast"] = (d / "podcast" / f"{a['id']}.json").is_file()
a["has_audio"] = (d / "audio" / f"{a['id']}.mp3").is_file()
return { return {
"articles": articles, "articles": articles,
"progress": meta.get("progress") or {}, "progress": meta.get("progress") or {},
@ -1166,7 +1169,9 @@ async def magazine_add_region(doc_id: str, page: int, request: Request):
and all(isinstance(v, (int, float)) for v in bbox)): and all(isinstance(v, (int, float)) for v in bbox)):
raise HTTPException(400, "bbox must be [x, y, width, height]") raise HTTPException(400, "bbox must be [x, y, width, height]")
kind = str(body.get("kind") or "text") kind = str(body.get("kind") or "text")
if kind not in ("text", "image", "heading", "page_number", "source", "ad"): if kind not in ("text", "body", "image", "title", "heading", "deck", "byline", "caption",
"pullquote", "sidebar", "page_number", "source", "in_picture", "ocr_noise",
"duplicate", "table", "ad"):
raise HTTPException(400, "Unsupported region kind") raise HTTPException(400, "Unsupported region kind")
text = str(body.get("text") or "")[:20000] text = str(body.get("text") or "")[:20000]
@ -1625,3 +1630,143 @@ async def magazine_podcast_delete(doc_id: str, article_id: str):
article_id = _article_id(article_id) article_id = _article_id(article_id)
(d / "podcast" / f"{article_id}.json").unlink(missing_ok=True) (d / "podcast" / f"{article_id}.json").unlink(missing_ok=True)
return {"ok": True} return {"ok": True}
# ── Standalone tagged MP3 export (Stage E) ───────────────────────────────────
# For listening away from this app entirely -- a phone's own music/podcast
# player, a car head unit -- not just the in-browser reader.
def _article_cover(d: Path, article: dict[str, Any]) -> tuple[bytes | None, str]:
page = (article.get("pages") or [None])[0]
if page is None:
return None, "image/png"
png = d / "pages" / f"{page:04d}.png"
if not png.is_file():
return None, "image/png"
try:
return png.read_bytes(), "image/png"
except OSError:
return None, "image/png"
def _synthesize_article_audio_sync(doc_id: str, job_id: str, article_id: str, voice: str,
total_hint: int | None = None, index: int | None = None,
count: int | None = None) -> Path:
d = _LIB / doc_id
meta = _read_meta(d)
layout = mag.load_layout(d)
if not layout:
raise FileNotFoundError("No layout yet -- run ingestion first")
articles = mag.load_articles(d)
article = next((a for a in articles if a.get("id") == article_id), None)
if article is None:
raise FileNotFoundError("Article not found")
if article.get("status") != "approved":
raise ValueError("Article is not approved yet")
settings = _load_settings()
cover_bytes, cover_mime = _article_cover(d, article)
prefix = f"({index}/{count}) " if index and count else ""
def progress(done: int, total: int) -> None:
background_job_update(
job_id, detail=f"{prefix}{article.get('title') or article_id}: sentence {done}/{total}")
return mag.synthesize_article_audio(
d, article, layout, voice, settings,
album=meta.get("title") or "", cover_bytes=cover_bytes, cover_mime=cover_mime,
progress_cb=progress,
)
def _audio_generate_one_sync(doc_id: str, job_id: str, article_id: str, voice: str) -> None:
try:
path = _synthesize_article_audio_sync(doc_id, job_id, article_id, voice)
background_job_update(job_id, stage="done", done=True, detail="Audio ready",
result={"article": article_id, "file": path.name})
except Exception as exc: # noqa: BLE001 - surfaced to UI
background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000])
@router.post("/api/magazine/{doc_id}/articles/{article_id}/audio")
async def magazine_generate_article_audio(doc_id: str, article_id: str, request: Request):
"""Render one approved article to a single tagged MP3 (title/author/album,
its own text as embedded lyrics, cover art) -- for taking it out of the app."""
d, meta = _require_magazine(doc_id)
article_id = _article_id(article_id)
body = await request.json() if await request.body() else {}
voice = str(body.get("voice") or meta.get("default_voice") or "").strip()
if not voice:
raise HTTPException(400, "No narrator voice given")
job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-article-audio", fingerprint=f"{doc_id}:{article_id}")
asyncio.create_task(asyncio.to_thread(_audio_generate_one_sync, doc_id, job_id, article_id, voice))
return {"job_id": job_id}
@router.get("/api/magazine/{doc_id}/articles/{article_id}/audio")
async def magazine_article_audio_file(doc_id: str, article_id: str):
d, _ = _require_magazine(doc_id)
article_id = _article_id(article_id)
path = d / "audio" / f"{article_id}.mp3"
if not path.is_file():
raise HTTPException(404, "No audio generated yet")
return FileResponse(path, media_type="audio/mpeg", filename=f"{article_id}.mp3")
def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str) -> None:
ok, failed = [], []
total = len(article_ids)
for i, article_id in enumerate(article_ids, start=1):
background_job_update(job_id, detail=f"Article {i}/{total}…")
try:
_synthesize_article_audio_sync(doc_id, job_id, article_id, voice, index=i, count=total)
ok.append(article_id)
except Exception as exc: # noqa: BLE001 - one bad article should not stop the rest
failed.append({"article": article_id, "error": str(exc)[:300]})
background_job_update(
job_id, stage="done", done=True,
detail=f"{len(ok)} of {total} article{'s' if total != 1 else ''} rendered" + (f", {len(failed)} failed" if failed else ""),
result={"ok": ok, "failed": failed},
)
@router.post("/api/magazine/{doc_id}/audio/batch")
async def magazine_generate_batch_audio(doc_id: str, request: Request):
"""Render every approved article (or a given subset) to a tagged MP3, one
background job for the whole run so the browser does not have to stay
open article by article."""
d, meta = _require_magazine(doc_id)
body = await request.json() if await request.body() else {}
voice = str(body.get("voice") or meta.get("default_voice") or "").strip()
if not voice:
raise HTTPException(400, "No narrator voice given")
requested = body.get("article_ids")
articles = mag.load_articles(d)
if isinstance(requested, list) and requested:
wanted = {str(x) for x in requested}
article_ids = [a["id"] for a in articles if a["id"] in wanted and a.get("status") == "approved"]
else:
article_ids = [a["id"] for a in articles if a.get("status") == "approved"]
if not article_ids:
raise HTTPException(400, "No approved articles to render")
job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-audio-batch", fingerprint=doc_id)
asyncio.create_task(asyncio.to_thread(_audio_generate_batch_sync, doc_id, job_id, article_ids, voice))
return {"job_id": job_id, "count": len(article_ids)}
@router.get("/api/magazine/{doc_id}/audio.zip")
async def magazine_audio_zip(doc_id: str):
d, meta = _require_magazine(doc_id)
audio_dir = d / "audio"
files = sorted(audio_dir.glob("*.mp3")) if audio_dir.is_dir() else []
if not files:
raise HTTPException(404, "No audio generated yet")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
for f in files:
zf.write(f, arcname=f.name)
buf.seek(0)
safe_title = re.sub(r"[^\w \-]+", "", meta.get("title") or "magazine").strip() or "magazine"
return Response(content=buf.getvalue(), media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{safe_title}.zip"'})

View File

@ -22,6 +22,9 @@ const state = {
sentences: [], // [{ text, blockId, page, bbox }] sentences: [], // [{ text, blockId, page, bbox }]
images: [], // current article's figures, in reading order images: [], // current article's figures, in reading order
view: 'library', // library | contents | review | read view: 'library', // library | contents | review | read
contentsView: 'list', // list | grid — how the article list itself is shown
contentsSelectMode: false, // batch-select articles for bulk approve/reject
contentsSelected: new Set(),
pane: 'reflow', // reflow | pdf pane: 'reflow', // reflow | pdf
playing: false, playing: false,
cancel: false, cancel: false,
@ -392,6 +395,7 @@ async function magCreate(droppedFile = null) {
// completely different kind ("book", via Studio/Reader's own import) because // completely different kind ("book", via Studio/Reader's own import) because
// the magazine they had been working on appeared to have vanished. // the magazine they had been working on appeared to have vanished.
const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc'; const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc';
const MAG_CONTENTS_VIEW_KEY = 'ttsvc_mag_contents_view';
async function magOpen(docId) { async function magOpen(docId) {
try { try {
@ -633,16 +637,69 @@ function renderContents() {
bySection.get(key).push(a); bySection.get(key).push(a);
}); });
const grid = state.contentsView === 'grid';
const toggleBtn = $('mag-contents-view-toggle');
if (toggleBtn) {
toggleBtn.innerHTML = grid
? '<span class="mdi mdi-view-list-outline"></span> List'
: '<span class="mdi mdi-view-grid-outline"></span> Thumbnails';
toggleBtn.setAttribute('aria-pressed', String(grid));
}
const actionsHtml = (a, done, progress) =>
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' +
(done
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
(progress ? 'Continue' : 'Listen') + '</button>'
: '') +
(a.has_audio
? '<a class="btn-sm" href="/api/magazine/' + esc(state.doc.id) + '/articles/' + a.id + '/audio" download ' +
'title="Download the exported MP3 (with title, author, cover and lyrics tags)"><span class="mdi mdi-download"></span> MP3</a>'
: '');
const selectHtml = (a) => !state.contentsSelectMode ? '' :
'<label class="mag-select-check" onclick="event.stopPropagation()">' +
'<input type="checkbox" ' + (state.contentsSelected.has(a.id) ? 'checked' : '') +
' onchange="magContentsToggleArticle(\'' + a.id + '\')" aria-label="Select ' + esc(a.title || 'this article') + '"></label>';
// Detection routinely mangles a title through OCR ("Sbimmb das? c'b
// prüFb") -- fixing it should not require opening Review. contenteditable
// rather than swapping in an <input> keeps the card's own font/wrapping
// instead of a generic text box.
const titleHtml = (a, cls) =>
'<span class="' + cls + '" contenteditable="true" spellcheck="false" ' +
'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' +
'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' +
'onclick="event.stopPropagation()" title="Click to edit the title">' +
esc(a.title || 'Untitled') + '</span>';
let html = ''; let html = '';
bySection.forEach((arts, section) => { bySection.forEach((arts, section) => {
html += '<div class="mag-section"><h4>' + esc(section) + '</h4><ul class="mag-toc">'; html += '<div class="mag-section"><h4>' + esc(section) + '</h4>' + (grid ? '<div class="mag-grid">' : '<ul class="mag-toc">');
arts.forEach((a) => { arts.forEach((a) => {
const conf = confidenceLabel(a.confidence); const conf = confidenceLabel(a.confidence);
const done = a.status === 'approved'; const done = a.status === 'approved';
const progress = (state.doc.progress || {})[a.id]; const progress = (state.doc.progress || {})[a.id];
const statusBadge = '<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>';
if (grid) {
const firstPage = (a.pages || [])[0];
html += '<div class="mag-card-wrap"><div class="mag-card' + (done ? ' is-approved' : '') + '">' +
selectHtml(a) +
(firstPage
? '<img class="mag-card-thumb" loading="lazy" alt="Page ' + firstPage + '" ' +
'src="/api/magazine/' + esc(state.doc.id) + '/pages/' + firstPage + '">'
: '<div class="mag-card-thumb"></div>') +
titleHtml(a, 'mag-card-title') +
(a.author ? '<span class="mag-card-sub">by ' + esc(a.author) + '</span>' : '') +
'<span class="mag-card-sub">' + fmtPages(a.pages) + ' · ' + fmtDuration(a.estimated_seconds) + '</span>' +
'<div class="mag-toc-meta"><span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
esc(conf.badge) + '</span>' + statusBadge + '</div>' +
'<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div>' +
'</div></div>';
} else {
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + '">' + html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + '">' +
selectHtml(a) +
'<div class="mag-toc-main">' + '<div class="mag-toc-main">' +
'<span class="mag-toc-title">' + esc(a.title || 'Untitled') + '</span>' + titleHtml(a, 'mag-toc-title') +
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') + (a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
'</div>' + '</div>' +
'<div class="mag-toc-meta">' + '<div class="mag-toc-meta">' +
@ -650,20 +707,155 @@ function renderContents() {
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' + '<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' + '<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
esc(conf.badge) + '</span>' + esc(conf.badge) + '</span>' +
'<span class="mag-pill ' + (done ? 'ok' : '') + '">' + statusBadge +
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>' +
'</div>' + '</div>' +
'<div class="mag-toc-actions">' + '<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div></li>';
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' + }
(done
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
(progress ? 'Continue' : 'Listen') + '</button>'
: '') +
'</div></li>';
}); });
html += '</ul></div>'; html += grid ? '</div></div>' : '</ul></div>';
}); });
host.innerHTML = html; host.innerHTML = html;
renderContentsBulkBar();
magUpdateAudioZipLink();
}
function magToggleContentsView() {
state.contentsView = state.contentsView === 'grid' ? 'list' : 'grid';
try { localStorage.setItem(MAG_CONTENTS_VIEW_KEY, state.contentsView); } catch (_) {}
renderContents();
}
// ── Batch approve/reject from the article list ──────────────────────────────
// Reviewing every single article one at a time does not scale to a 200-page
// issue with 50+ detected pieces. This accepts each selected article exactly
// as auto-detected (no block-list edit, unlike a normal Approve from inside
// review) -- a deliberate trade-off: bulk action is for the pieces that
// already look right, not a replacement for reviewing a messy one.
function magContentsToggleSelect() {
state.contentsSelectMode = !state.contentsSelectMode;
if (!state.contentsSelectMode) state.contentsSelected.clear();
const btn = $('mag-contents-select-toggle');
if (btn) btn.setAttribute('aria-pressed', String(state.contentsSelectMode));
renderContents();
}
async function magContentsCommitTitle(el, id) {
const a = state.articles.find((x) => x.id === id);
if (!a) return;
const value = (el.textContent || '').replace(/\s+/g, ' ').trim();
if (!value || value === (a.title || '')) { el.textContent = a.title || 'Untitled'; return; }
const previous = a.title;
a.title = value;
try {
await api('/api/magazine/' + state.doc.id + '/articles/' + id, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: value }),
});
} catch (e) {
a.title = previous;
el.textContent = previous || 'Untitled';
say('Could not save the title: ' + e.message, 'err');
}
}
function magContentsToggleArticle(id) {
if (state.contentsSelected.has(id)) state.contentsSelected.delete(id);
else state.contentsSelected.add(id);
renderContents();
}
function magContentsSelectAllVisible() {
state.articles.forEach((a) => state.contentsSelected.add(a.id));
renderContents();
}
function magContentsClearSelection() {
state.contentsSelected.clear();
renderContents();
}
async function magContentsBulkStatus(status) {
const ids = Array.from(state.contentsSelected);
if (!ids.length) return;
const bar = $('mag-contents-bulkbar');
let done = 0;
for (const id of ids) {
if (bar) bar.querySelector('.mag-bulkbar-progress')?.replaceChildren(
document.createTextNode(`${status === 'approved' ? 'Approving' : 'Rejecting'} ${done + 1} of ${ids.length}…`));
try {
await api('/api/magazine/' + state.doc.id + '/articles/' + id, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
} catch (e) { say('Could not update ' + id + ': ' + e.message, 'err'); }
done += 1;
}
state.contentsSelected.clear();
say(`${done} article${done === 1 ? '' : 's'} ${status}`, 'ok');
await magRefreshArticles();
renderContents();
}
function renderContentsBulkBar() {
const bar = $('mag-contents-bulkbar');
if (!bar) return;
const n = state.contentsSelected.size;
if (!state.contentsSelectMode) { bar.hidden = true; bar.innerHTML = ''; return; }
bar.hidden = false;
bar.innerHTML =
'<span class="mag-bulkbar-count">' + n + ' selected</span>' +
'<span class="mag-bulkbar-progress muted"></span>' +
'<div class="mag-bulkbar-actions">' +
'<button type="button" class="btn-sm" onclick="magContentsSelectAllVisible()">Select all</button>' +
'<button type="button" class="btn-sm" onclick="magContentsClearSelection()" ' + (n ? '' : 'disabled') + '>Clear</button>' +
'<button type="button" class="btn-sm" onclick="magContentsBulkStatus(\'rejected\')" ' + (n ? '' : 'disabled') + '>Reject selected</button>' +
'<button type="button" class="btn-sm primary" onclick="magContentsBulkStatus(\'approved\')" ' + (n ? '' : 'disabled') + '>Approve selected</button>' +
'</div>';
}
// ── Standalone MP3 export (title/author/cover/lyrics tags) ─────────────────
// For listening away from this app entirely -- a phone's own music/podcast
// player, a car head unit -- not the in-browser sentence-by-sentence player.
async function magGenerateAllAudio() {
const voice = ($('mag-audio-voice')?.value || '').trim();
if (!voice) return say('Pick a narrator voice for the export first', 'err');
const btn = $('mag-audio-export-btn');
const status = $('mag-audio-export-status');
if (btn) btn.disabled = true;
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/audio/batch', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice }),
});
jobId = r.job_id;
} catch (e) {
if (btn) btn.disabled = false;
return say('Could not start audio export: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); }
catch (e) { if (btn) btn.disabled = false; return say('Could not check audio export: ' + e.message, 'err'); }
if (status) status.textContent = job.detail || 'Working…';
if (job.done) {
say(job.error ? 'Audio export failed: ' + job.error : (job.detail || 'Audio export done'), job.error ? 'err' : 'ok');
break;
}
await new Promise((r) => setTimeout(r, 1500));
}
if (btn) btn.disabled = false;
if (status) status.textContent = '';
await magRefreshArticles();
renderContents();
}
function magUpdateAudioZipLink() {
const link = $('mag-audio-zip-link');
if (!link) return;
const any = state.articles.some((a) => a.has_audio);
link.hidden = !any;
if (any) link.href = '/api/magazine/' + state.doc.id + '/audio.zip';
} }
// ── Review (Stage D) ───────────────────────────────────────────────────────── // ── Review (Stage D) ─────────────────────────────────────────────────────────
@ -731,7 +923,7 @@ const MAG_REGION_TYPES = [
['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'],
['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'], ['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'],
['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'], ['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'],
['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['image', 'Image'], ['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['table', 'Table'], ['image', 'Image'],
]; ];
// Furniture kinds that the picker shows under their own name (everything else is a page number). // Furniture kinds that the picker shows under their own name (everything else is a page number).
const MAG_FURNITURE_KINDS = ['source', 'in_picture', 'ocr_noise', 'duplicate']; const MAG_FURNITURE_KINDS = ['source', 'in_picture', 'ocr_noise', 'duplicate'];
@ -881,7 +1073,13 @@ function magReviewPageColumn(page, wrapId, interactive) {
if (isImage) { if (isImage) {
const on = ownImages.has(r.id); const on = ownImages.has(r.id);
const imgFilename = r.data.filename || (r.id + '.png'); const imgFilename = r.data.filename || (r.id + '.png');
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename); // Resizing an image region re-crops the same filename in place (see
// update_region in core/magazine.py) -- an identical URL never
// re-fetches in the browser, so the old crop kept showing after a
// resize was saved. bbox changes exactly when the crop does, so it
// makes a cheap, correct cache-busting key.
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename) +
'?v=' + encodeURIComponent((r.data.bbox || []).join(','));
listHtml += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' + listHtml += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' + 'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
magOrderNumber(r.id, i + 1, regions.length, interactive) + magOrderNumber(r.id, i + 1, regions.length, interactive) +
@ -941,14 +1139,6 @@ function magReviewSetActivePage(page) {
renderReview(); renderReview();
} }
function magReviewStepPage(dir) {
const pages = state.article?.pages || [];
const next = pages[pages.indexOf(state.reviewPage) + dir];
if (next == null) return;
state.reviewPage = next;
renderReview();
}
function renderReview() { function renderReview() {
const host = $('mag-review'); const host = $('mag-review');
const a = state.article; const a = state.article;
@ -959,7 +1149,6 @@ function renderReview() {
const conf = confidenceLabel(a.confidence); const conf = confidenceLabel(a.confidence);
const page = state.reviewPage; const page = state.reviewPage;
const pageCount = Number(state.layout?.page_count || 0); const pageCount = Number(state.layout?.page_count || 0);
const pageIdx = (a?.pages || []).indexOf(page);
const secondaryPage = state.reviewSpread ? magSpreadPartner(page, pageCount) : null; const secondaryPage = state.reviewSpread ? magSpreadPartner(page, pageCount) : null;
const spreadPages = secondaryPage != null ? [page, secondaryPage].sort((x, y) => x - y) : null; const spreadPages = secondaryPage != null ? [page, secondaryPage].sort((x, y) => x - y) : null;
const issuePages = pageCount || '—'; const issuePages = pageCount || '—';
@ -968,17 +1157,14 @@ function renderReview() {
let html = ''; let html = '';
html += '<div class="mag-review-pagenav">' + html += '<div class="mag-review-pagenav">' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Review pages">' + // One page stepper for the whole magazine, not a separate one bounded
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (pageIdx > 0 ? '' : 'disabled') + // to just this article's own pages — the two almost always agreed and
' onclick="magReviewStepPage(-1)"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' + // having both doubled the toolbar's width for no real benefit.
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' + '<div class="mag-review-pagenav-group" role="group" aria-label="Magazine page">' +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (pageIdx >= 0 && pageIdx < (a?.pages || []).length - 1 ? '' : 'disabled') + '<button type="button" class="btn-sm" aria-label="Previous page" ' + (prevIssuePage ? '' : 'disabled') +
' onclick="magReviewStepPage(1)"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Whole magazine pages">' +
'<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (prevIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + prevIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' + ' onclick="magReviewGoMagazinePage(' + prevIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Magazine ' + (page ?? '—') + ' / ' + issuePages + '</strong>' + '<strong class="mag-review-page-number">Page ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next magazine page" ' + (nextIssuePage ? '' : 'disabled') + '<button type="button" class="btn-sm" aria-label="Next page" ' + (nextIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' + ' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Spread view">' + '<div class="mag-review-pagenav-group" role="group" aria-label="Spread view">' +
'<button type="button" class="btn-sm" id="mag-spread-toggle" aria-pressed="' + String(!!state.reviewSpread) + '" ' + '<button type="button" class="btn-sm" id="mag-spread-toggle" aria-pressed="' + String(!!state.reviewSpread) + '" ' +
@ -987,17 +1173,7 @@ function renderReview() {
(state.reviewSpread ? 'Single page' : 'Two-page spread') + '</button></div>' + (state.reviewSpread ? 'Single page' : 'Two-page spread') + '</button></div>' +
'<div class="mag-review-draw-controls" role="group" aria-label="Draw regions">' + '<div class="mag-review-draw-controls" role="group" aria-label="Draw regions">' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw()" id="mag-draw-toggle">' + '<button type="button" class="btn-sm" onclick="magReviewToggleDraw()" id="mag-draw-toggle">' +
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw text</button>' + '<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw region</button>' +
'<button type="button" class="btn-sm" id="mag-draw-image-toggle" onclick="magReviewToggleDraw(\'image\')">' +
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw image</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'heading\')">' +
'<span class="mdi mdi-format-header-1" aria-hidden="true"></span> Headline</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'page_number\')">' +
'<span class="mdi mdi-pound" aria-hidden="true"></span> Page number</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'source\')">' +
'<span class="mdi mdi-book-open-page-variant-outline" aria-hidden="true"></span> Source / footer</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'ad\')">' +
'<span class="mdi mdi-advertisements-outline" aria-hidden="true"></span> Advertisement</button>' +
'<button type="button" class="btn-sm" id="mag-edit-toggle" aria-pressed="false" ' + '<button type="button" class="btn-sm" id="mag-edit-toggle" aria-pressed="false" ' +
'title="Drag a region to move or resize it, or delete one that was detected wrongly" ' + 'title="Drag a region to move or resize it, or delete one that was detected wrongly" ' +
'onclick="magReviewToggleEdit()"><span class="mdi mdi-vector-polygon" aria-hidden="true"></span> Edit boxes</button>' + 'onclick="magReviewToggleEdit()"><span class="mdi mdi-vector-polygon" aria-hidden="true"></span> Edit boxes</button>' +
@ -1007,7 +1183,9 @@ function renderReview() {
'<button type="button" class="btn-sm" id="mag-order-toggle" aria-pressed="' + String(!!state.orderMode) + '" ' + '<button type="button" class="btn-sm" id="mag-order-toggle" aria-pressed="' + String(!!state.orderMode) + '" ' +
'title="Click the boxes on the page in the order they should be read" onclick="magOrderToggle()">' + 'title="Click the boxes on the page in the order they should be read" onclick="magOrderToggle()">' +
'<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> Order by clicking</button>' + '<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> Order by clicking</button>' +
'<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button></div>' + '<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button>' +
'<button type="button" class="btn-sm" onclick="magReviewRemoveAllBoxes()" title="Delete every detected box on this page, so you can draw the layout from scratch">' +
'<span class="mdi mdi-eraser" aria-hidden="true"></span> Remove all boxes</button></div>' +
'<span class="mag-review-pagenav-spacer"></span>' + '<span class="mag-review-pagenav-spacer"></span>' +
'<button type="button" class="btn-sm" id="mag-reading-order-btn" onclick="magFixReadingOrder()" ' + '<button type="button" class="btn-sm" id="mag-reading-order-btn" onclick="magFixReadingOrder()" ' +
'title="Re-read every page column by column, left to right. Text and approvals stay as they are; pages you ordered by hand are kept." >' + 'title="Re-read every page column by column, left to right. Text and approvals stay as they are; pages you ordered by hand are kept." >' +
@ -1202,6 +1380,40 @@ function magReviewClearCurrentPage() {
say('All fields on this page cleared — select only the article content to keep.', 'ok'); say('All fields on this page cleared — select only the article content to keep.', 'ok');
} }
// "Clear all fields" above only deselects a page's regions from narration --
// the boxes themselves, right or wrong, stay. When the detected layout on a
// page is too tangled to fix by editing individual boxes, this deletes every
// region on the page outright so "Draw region" can rebuild it from nothing.
// Goes through the same bulk endpoint as multi-select delete, so it gets the
// same one-shot Undo.
async function magReviewRemoveAllBoxes() {
if (state.reviewPage == null) return;
const page = state.reviewPage;
const ids = [
...(state.layout.blocks || []).filter((b) => b.page === page).map((b) => b.id),
...(state.layout.images || []).filter((im) => im.page === page).map((im) => im.id),
];
if (!ids.length) return say('No boxes on this page to remove', '');
const ok = await confirmDialog(
`Delete all ${ids.length} box${ids.length === 1 ? '' : 'es'} on page ${page}? ` +
'This can be undone with the Undo button that appears after.',
{ title: 'Remove all boxes?', okLabel: 'Remove all' });
if (!ok) return;
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/regions/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, action: 'delete' }),
});
magOfferUndo(r?.undo, `Removed ${ids.length} box${ids.length === 1 ? '' : 'es'}.`);
} catch (e) {
return say('Could not remove boxes: ' + e.message, 'err');
}
state.layout = null;
await ensureLayout();
renderReview();
say('All boxes on this page removed', 'ok');
}
// ── Reading order by hand ───────────────────────────────────────────────── // ── Reading order by hand ─────────────────────────────────────────────────
// The number in front of each row is its reading position. Typing a new number moves the region // The number in front of each row is its reading position. Typing a new number moves the region
// there; "Order by clicking" lets the reader click the boxes on the page in reading order. Either // there; "Order by clicking" lets the reader click the boxes on the page in reading order. Either
@ -1469,16 +1681,13 @@ function magReviewToggleDraw(kind = 'text') {
if (state.drawMode && state.editMode) magReviewToggleEdit(); if (state.drawMode && state.editMode) magReviewToggleEdit();
const hint = $('mag-draw-hint'); const hint = $('mag-draw-hint');
const field = kind === 'title' ? 'title' : kind === 'author' ? 'author' : ''; const field = kind === 'title' ? 'title' : kind === 'author' ? 'author' : '';
const drawLabels = { text: 'text region', image: 'image, chart, or graphic', heading: 'headline', page_number: 'page number', source: 'source or footer', ad: 'advertisement' };
if (hint) hint.textContent = state.drawMode if (hint) hint.textContent = state.drawMode
? (field ? ` Drag around the ${field} text to OCR it into this field. Press Escape to cancel.` ? (field ? ` Drag around the ${field} text to OCR it into this field. Press Escape to cancel.`
: ' Drag around the whole ' + (drawLabels[kind] || 'text region') + '. Press Escape to cancel.') : ''; : ' Drag around the region, then say what it is. Press Escape to cancel.') : '';
const wrap = $('mag-review-page-wrap'); const wrap = $('mag-review-page-wrap');
if (wrap) wrap.classList.toggle('is-drawing', state.drawMode); if (wrap) wrap.classList.toggle('is-drawing', state.drawMode);
const btn = $('mag-draw-toggle'); const btn = $('mag-draw-toggle');
if (btn) { btn.classList.toggle('active', state.drawMode && kind === 'text'); btn.setAttribute('aria-pressed', String(state.drawMode && kind === 'text')); } if (btn) { btn.classList.toggle('active', state.drawMode && kind === 'text'); btn.setAttribute('aria-pressed', String(state.drawMode && kind === 'text')); }
const imageBtn = $('mag-draw-image-toggle');
if (imageBtn) { imageBtn.classList.toggle('active', state.drawMode && kind === 'image'); imageBtn.setAttribute('aria-pressed', String(state.drawMode && kind === 'image')); }
magReviewCancelDrawPanel(); magReviewCancelDrawPanel();
} }
@ -1941,26 +2150,25 @@ function magReviewOpenDrawPanel(box) {
const panel = document.createElement('div'); const panel = document.createElement('div');
panel.className = 'mag-draw-panel'; panel.className = 'mag-draw-panel';
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : ''; const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
const semanticKinds = { heading: 'headline', page_number: 'page number', source: 'source / footer', ad: 'advertisement' }; // One generic draw tool now covers every region kind (previously six
const semanticLabel = semanticKinds[state.drawKind] || ''; // separate "Draw X" buttons pre-selected the kind before drawing) — the
// kind is picked here, after the rectangle is already on the page, since
// that's the order a person actually works in: see the shape, then say
// what it is.
const drawKindOptions = MAG_REGION_TYPES;
panel.innerHTML = field panel.innerHTML = field
? '<div class="mag-draw-panel-row"><strong>Extract ' + field + '</strong></div>' + ? '<div class="mag-draw-panel-row"><strong>Extract ' + field + '</strong></div>' +
'<p class="muted">OCR the selected rectangle and use its recognised text for the article ' + field + '.</p>' + '<p class="muted">OCR the selected rectangle and use its recognised text for the article ' + field + '.</p>' +
'<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' + '<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-text-recognition"></span> Use recognised text</button></div>' '<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-text-recognition"></span> Use recognised text</button></div>'
: semanticLabel
? '<div class="mag-draw-panel-row"><strong>Add ' + semanticLabel + '</strong></div>' +
'<p class="muted">This region is classified as ' + semanticLabel + (state.drawKind === 'heading' ? ' and can be included in narration.' : ' and is kept out of narration.') + '</p>' +
'<textarea id="mag-draw-text" rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
'<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-plus" aria-hidden="true"></span> Add ' + semanticLabel + '</button></div>'
: :
'<div class="mag-draw-panel-row">' + '<div class="mag-draw-panel-row">' +
'<label><input type="radio" name="mag-draw-kind" value="text" ' + (state.drawKind !== 'image' ? 'checked' : '') + '> Text</label>' + '<label>Region type <select id="mag-draw-kind">' +
'<label><input type="radio" name="mag-draw-kind" value="image" ' + (state.drawKind === 'image' ? 'checked' : '') + '> Image</label>' + drawKindOptions.map(([v, label]) => '<option value="' + v + '">' + label + '</option>').join('') +
'</select></label>' +
'</div>' + '</div>' +
'<label id="mag-draw-exclude"' + (state.drawKind === 'image' ? '' : ' hidden') + '><input id="mag-draw-exclude-check" type="checkbox" checked> Exclude enclosed text from narration</label>' + '<label id="mag-draw-exclude" hidden><input id="mag-draw-exclude-check" type="checkbox" checked> Exclude enclosed text from narration</label>' +
'<textarea id="mag-draw-text"' + (state.drawKind === 'image' ? ' hidden' : '') + ' rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' + '<textarea id="mag-draw-text" rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
'<div class="mag-draw-panel-row">' + '<div class="mag-draw-panel-row">' +
'<button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' + '<button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()">' + '<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()">' +
@ -1970,14 +2178,16 @@ function magReviewOpenDrawPanel(box) {
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0; const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0;
panel.style.top = Math.min(85, top + height + 1) + '%'; panel.style.top = Math.min(85, top + height + 1) + '%';
panel.style.left = Math.min(60, left) + '%'; panel.style.left = Math.min(60, left) + '%';
wrap.querySelectorAll('input[name="mag-draw-kind"]').forEach((r) => { const kindSelect = $('mag-draw-kind');
r.addEventListener('change', () => { if (kindSelect) {
kindSelect.addEventListener('change', () => {
const isImage = kindSelect.value === 'image';
const ta = $('mag-draw-text'); const ta = $('mag-draw-text');
if (ta) ta.hidden = r.value === 'image' && r.checked; if (ta) ta.hidden = isImage;
$('mag-draw-exclude').hidden = r.value !== 'image'; $('mag-draw-exclude').hidden = !isImage;
});
}); });
} }
}
async function magReviewSubmitDraw() { async function magReviewSubmitDraw() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
@ -1985,8 +2195,7 @@ async function magReviewSubmitDraw() {
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage); const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
if (!box || !pageMeta) return; if (!box || !pageMeta) return;
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : ''; const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
const kind = field ? 'text' : (['heading', 'page_number', 'source', 'ad'].includes(state.drawKind) const kind = field ? 'text' : ($('mag-draw-kind')?.value || 'text');
? state.drawKind : (document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text'));
const text = ($('mag-draw-text')?.value || '').trim(); const text = ($('mag-draw-text')?.value || '').trim();
const bbox = [ const bbox = [
parseFloat(box.style.left) / 100 * pageMeta.width, parseFloat(box.style.left) / 100 * pageMeta.width,
@ -2024,7 +2233,9 @@ async function magReviewSubmitDraw() {
try { try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions', { const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bbox, kind, text, ocr: kind !== 'image' && !text }), // Every text-like kind gets read automatically if left blank — except
// an advertisement, which is a graphic, not something worth OCRing.
body: JSON.stringify({ bbox, kind, text, ocr: kind !== 'image' && kind !== 'ad' && !text }),
}); });
jobId = r.job_id; jobId = r.job_id;
} catch (e) { } catch (e) {
@ -2192,8 +2403,15 @@ async function magApprove() {
}); });
Object.assign(a, updated); Object.assign(a, updated);
say('Approved — ready to listen', 'ok'); say('Approved — ready to listen', 'ok');
// Approving one article at a time is a queue, not a one-off — staying on
// the list meant re-finding and re-opening whichever article came next
// after every single approval. Go straight to it instead, and only fall
// back to the list once nothing is left to review.
const idx = state.articles.findIndex((x) => x.id === a.id);
const nextId = idx >= 0 ? state.articles[idx + 1]?.id : null;
await magRefreshArticles(); await magRefreshArticles();
magShow('contents'); if (nextId && state.articles.some((x) => x.id === nextId)) magReview(nextId);
else magShow('contents');
} catch (e) { } catch (e) {
say('Could not approve: ' + e.message, 'err'); say('Could not approve: ' + e.message, 'err');
} }
@ -2683,7 +2901,7 @@ async function magLoadVoices() {
if (root.VoicePicker && el.dataset.vpUpgraded) root.VoicePicker.setValue(id, value); if (root.VoicePicker && el.dataset.vpUpgraded) root.VoicePicker.setValue(id, value);
else el.value = value; else el.value = value;
}; };
const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice' }; const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice', 'mag-audio-voice': 'Narrator for exported MP3s' };
Object.keys(voiceLabels).forEach((id) => { Object.keys(voiceLabels).forEach((id) => {
const el = $(id); const el = $(id);
if (!el) return; if (!el) return;
@ -2704,6 +2922,7 @@ async function magLoadVoices() {
}); });
const def = state.doc && state.doc.default_voice; const def = state.doc && state.doc.default_voice;
if (def && $('mag-voice') && !$('mag-voice').value) setVoice('mag-voice', def); if (def && $('mag-voice') && !$('mag-voice').value) setVoice('mag-voice', def);
if (def && $('mag-audio-voice') && !$('mag-audio-voice').value) setVoice('mag-audio-voice', def);
} }
async function loadPodcastIfAny() { async function loadPodcastIfAny() {
@ -2880,6 +3099,10 @@ async function magOnShow() {
async function magInit() { async function magInit() {
setupMagDropzone(); setupMagDropzone();
setupMagIngestPreviewResize(); setupMagIngestPreviewResize();
try {
const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY);
if (savedView === 'grid' || savedView === 'list') state.contentsView = savedView;
} catch (_) {}
if (!$('mag-library-list')) return; // section not mounted if (!$('mag-library-list')) return; // section not mounted
magLoadLibrary(); magLoadLibrary();
magLoadVoices(); magLoadVoices();
@ -2908,15 +3131,23 @@ root.magDelete = magDelete;
root.magCreate = magCreate; root.magCreate = magCreate;
root.magIngest = magIngest; root.magIngest = magIngest;
root.magReview = magReview; root.magReview = magReview;
root.magToggleContentsView = magToggleContentsView;
root.magContentsToggleSelect = magContentsToggleSelect;
root.magContentsCommitTitle = magContentsCommitTitle;
root.magGenerateAllAudio = magGenerateAllAudio;
root.magContentsToggleArticle = magContentsToggleArticle;
root.magContentsSelectAllVisible = magContentsSelectAllVisible;
root.magContentsClearSelection = magContentsClearSelection;
root.magContentsBulkStatus = magContentsBulkStatus;
root.magReviewGoPage = magReviewGoPage; root.magReviewGoPage = magReviewGoPage;
root.magReviewGoMagazinePage = magReviewGoMagazinePage; root.magReviewGoMagazinePage = magReviewGoMagazinePage;
root.magReviewStepPage = magReviewStepPage;
root.magReviewToggleSpread = magReviewToggleSpread; root.magReviewToggleSpread = magReviewToggleSpread;
root.magReviewSetActivePage = magReviewSetActivePage; root.magReviewSetActivePage = magReviewSetActivePage;
root.magReviewFocusBlock = magReviewFocusBlock; root.magReviewFocusBlock = magReviewFocusBlock;
root.magReviewHighlightBox = magReviewHighlightBox; root.magReviewHighlightBox = magReviewHighlightBox;
root.magReviewToggleBox = magReviewToggleBox; root.magReviewToggleBox = magReviewToggleBox;
root.magReviewClearCurrentPage = magReviewClearCurrentPage; root.magReviewClearCurrentPage = magReviewClearCurrentPage;
root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes;
root.magReocrPage = magReocrPage; root.magReocrPage = magReocrPage;
root.magRefineFigures = magRefineFigures; root.magRefineFigures = magRefineFigures;
root.magReviewToggleImage = magReviewToggleImage; root.magReviewToggleImage = magReviewToggleImage;

View File

@ -58,6 +58,12 @@
</button> </button>
<h3 id="mag-doc-title">Magazine</h3> <h3 id="mag-doc-title">Magazine</h3>
<div class="mag-bar-actions"> <div class="mag-bar-actions">
<button type="button" class="btn-sm" id="mag-contents-view-toggle" onclick="magToggleContentsView()" title="Switch between a list and a thumbnail grid of articles">
<span class="mdi mdi-view-grid-outline"></span> Thumbnails
</button>
<button type="button" class="btn-sm" id="mag-contents-select-toggle" aria-pressed="false" onclick="magContentsToggleSelect()" title="Select several articles to approve or reject at once">
<span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select
</button>
<button type="button" class="btn-sm primary" onclick="magPlayAll()"> <button type="button" class="btn-sm primary" onclick="magPlayAll()">
<span class="mdi mdi-playlist-play"></span> Play all articles <span class="mdi mdi-playlist-play"></span> Play all articles
</button> </button>
@ -69,6 +75,19 @@
</button> </button>
</div> </div>
</div> </div>
<div id="mag-contents-bulkbar" class="mag-bulkbar" hidden></div>
<div class="mag-audio-export">
<label for="mag-audio-voice">Narrator for exported MP3s</label>
<select id="mag-audio-voice"></select>
<button type="button" class="btn-sm" id="mag-audio-export-btn" onclick="magGenerateAllAudio()"
title="Render every approved article to a single MP3 with its title, author, cover image and full text embedded as tags -- for a phone's own music/podcast player or a car head unit">
<span class="mdi mdi-file-music-outline"></span> Generate all audio (MP3)
</button>
<a class="btn-sm" id="mag-audio-zip-link" href="#" hidden download>
<span class="mdi mdi-folder-zip-outline"></span> Download all (ZIP)
</a>
<span id="mag-audio-export-status" class="muted" role="status" aria-live="polite"></span>
</div>
<div id="mag-ingest-status" class="mag-status" hidden></div> <div id="mag-ingest-status" class="mag-status" hidden></div>
<!-- Decorative "watch it work" visualisation -- the essential <!-- Decorative "watch it work" visualisation -- the essential
progress information (percentage, page count, done/failed) is progress information (percentage, page count, done/failed) is

View File

@ -4916,6 +4916,33 @@ input[list="lang-codes"] {
.mag-toc-by { color: var(--subtext); font-size: 13px; margin-left: 8px; } .mag-toc-by { color: var(--subtext); font-size: 13px; margin-left: 8px; }
.mag-toc-meta { display: flex; gap: 10px; align-items: center; color: var(--subtext); font-size: 13px; flex-wrap: wrap; } .mag-toc-meta { display: flex; gap: 10px; align-items: center; color: var(--subtext); font-size: 13px; flex-wrap: wrap; }
.mag-toc-actions { display: flex; gap: 6px; } .mag-toc-actions { display: flex; gap: 6px; }
.mag-select-check { display: flex; align-items: center; padding-right: 4px; }
.mag-select-check input { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
.mag-toc-row { grid-template-columns: auto 1fr auto auto; }
.mag-card { position: relative; }
.mag-card .mag-select-check { position: absolute; top: 8px; left: 8px; z-index: 1;
background: var(--surface); border-radius: 4px; padding: 2px; }
.mag-bulkbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 14px; margin-bottom: 12px; border: 1px solid var(--accent); border-radius: var(--radius);
background: var(--surface); position: sticky; top: 0; z-index: 25; }
.mag-bulkbar-count { font-weight: 600; }
.mag-bulkbar-progress { flex: 1; min-width: 0; }
.mag-bulkbar-actions { display: flex; gap: 6px; margin-left: auto; }
.mag-audio-export { display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 10px 14px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--panel); }
.mag-audio-export label { color: var(--subtext); font-size: 13px; }
.mag-audio-export select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text); }
/* Clicking a title edits it in place -- a dashed underline on hover/focus is
the only cue it's editable, since a permanent border on every card title
would be noisy on a page listing 50+ articles. */
.mag-card-title[contenteditable], .mag-toc-title[contenteditable] { cursor: text; border-radius: 3px; outline: none; }
.mag-card-title[contenteditable]:hover, .mag-toc-title[contenteditable]:hover { text-decoration: underline dashed; }
.mag-card-title[contenteditable]:focus, .mag-toc-title[contenteditable]:focus { box-shadow: 0 0 0 2px var(--accent); }
.mag-review-head { display: grid; grid-template-columns: 2fr 1fr auto; gap: 12px; align-items: end; margin-bottom: 10px; } .mag-review-head { display: grid; grid-template-columns: 2fr 1fr auto; gap: 12px; align-items: end; margin-bottom: 10px; }
.mag-review-head label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); } .mag-review-head label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); }
@ -4927,13 +4954,20 @@ input[list="lang-codes"] {
.mag-page-head { margin: 16px 0 6px; font-size: 13px; color: var(--subtext); } .mag-page-head { margin: 16px 0 6px; font-size: 13px; color: var(--subtext); }
/* ── Review page navigator + "OCR again" ─────────────────────────────────── */ /* ── Review page navigator + "OCR again" ─────────────────────────────────── */
.mag-review-pagenav { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin-bottom: 12px; } /* Sticky so the page nav and box tools stay reachable on a long page without
scrolling back up; tightened gaps/heights so the whole bar has a real
chance of fitting one row instead of always wrapping to two. */
.mag-review-pagenav { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
position: sticky; top: 0; z-index: 30; background: var(--bg);
padding: 8px 0; margin-bottom: 12px; box-shadow: 0 2px 6px rgba(0, 0, 0, .06); }
.mag-review-pagenav-group { display: flex; align-items: center; gap: 4px; }
.mag-review-page-controls { display: flex; align-items: center; gap: 8px; padding: 4px; .mag-review-page-controls { display: flex; align-items: center; gap: 8px; padding: 4px;
border: 1px solid var(--accent); border-radius: var(--radius); background: var(--surface); } border: 1px solid var(--accent); border-radius: var(--radius); background: var(--surface); }
.mag-review-page-number { min-width: 100px; text-align: center; font-size: 16px; color: var(--text); } .mag-review-page-number { min-width: 90px; text-align: center; font-size: 14px; color: var(--text); }
.mag-review-page-controls .btn-sm { min-width: 44px; min-height: 40px; font-size: 22px; } .mag-review-page-controls .btn-sm { min-width: 44px; min-height: 40px; font-size: 22px; }
.mag-review-draw-controls { display: flex; gap: 8px; } .mag-review-pagenav .btn-sm { padding: 6px 10px; min-height: 34px; }
.mag-review-draw-controls .btn-sm { min-height: 40px; } .mag-review-draw-controls { display: flex; gap: 6px; }
.mag-review-draw-controls .btn-sm { min-height: 34px; }
.mag-review-draw-controls .active { background: var(--accent); color: var(--on-accent); } .mag-review-draw-controls .active { background: var(--accent); color: var(--on-accent); }
#mag-draw-hint:not(:empty) { padding: 8px 12px; margin-bottom: 12px; border-left: 3px solid var(--accent); } #mag-draw-hint:not(:empty) { padding: 8px 12px; margin-bottom: 12px; border-left: 3px solid var(--accent); }
.mag-review-pagenav-spacer { flex: 1; min-width: 0; } .mag-review-pagenav-spacer { flex: 1; min-width: 0; }