From 94331320c0aebc9571a3b297c83d95a8a347f755 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 22 Sep 2026 20:14:11 +0200 Subject: [PATCH] feat(magazine): one-click draw tool, table regions, batch approve, MP3 export, remove-all-boxes 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 --- CHANGELOG.md | 22 ++ VERSION | 2 +- core/magazine.py | 123 +++++++++- requirements.txt | 1 + routes/conversation.py | 137 ++++++++++- routes/magazine.py | 149 +++++++++++- static/js/magazine.js | 397 +++++++++++++++++++++++++------- static/sections/s-magazine.html | 19 ++ static/style.css | 42 +++- 9 files changed, 785 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 564b62c..af168b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [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 ### Fixed diff --git a/VERSION b/VERSION index 6f7c8cd..b978278 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.42.4 +1.43.0 diff --git a/core/magazine.py b/core/magazine.py index b511abd..b024ebb 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -2657,24 +2657,48 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int, manual_type, region_label = { "text": (BLOCK_BODY, None), + "body": (BLOCK_BODY, None), + "title": (BLOCK_TITLE, 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"), "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), }.get(kind, (None, None)) if manual_type is None: raise ValueError("Unsupported manual region kind") 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" try: _crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path) - engines = [(vision_ocr_url, vision_ocr_model), - (vision_ocr_fallback_url, vision_ocr_fallback_model)] - regions = vision_ocr_page_chain(crop_path, engines, timeout=timeout) - if regions: - final_text = " ".join(r["text"] for r in regions).strip() + # 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), + (vision_ocr_fallback_url, vision_ocr_fallback_model)] + regions = vision_ocr_page_chain(crop_path, engines, timeout=timeout) + if regions: + 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: crop_path.unlink(missing_ok=True) 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) regions = None if vision_ocr_url and vision_ocr_model: - regions = vision_ocr_page_chain(crop_path, [ - (vision_ocr_url, vision_ocr_model), - (vision_ocr_fallback_url, vision_ocr_fallback_model), - ], timeout=timeout) + try: + regions = vision_ocr_page_chain(crop_path, [ + (vision_ocr_url, vision_ocr_model), + (vision_ocr_fallback_url, vision_ocr_fallback_model), + ], 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: 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() @@ -2990,6 +3018,7 @@ REGION_TYPES: dict[str, tuple[str, str]] = { "in_picture": (BLOCK_FURNITURE, "in_picture"), "ocr_noise": (BLOCK_FURNITURE, "ocr_noise"), "duplicate": (BLOCK_FURNITURE, "duplicate"), + "table": (BLOCK_TABLE, ""), "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"])) 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) diff --git a/requirements.txt b/requirements.txt index 54ac6dd..b4a9006 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ requests>=2.31.0 Pillow>=10.3.0 yt-dlp>=2024.3.10 pymupdf>=1.24.0 +mutagen>=1.47.0 diff --git a/routes/conversation.py b/routes/conversation.py index 2dd71f5..6faff82 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -1963,9 +1963,13 @@ def _character_is_leather_only_armor(sheet: dict) -> bool: _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"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, ) @@ -1989,6 +1993,36 @@ def _character_has_established_combat_gear(sheet: dict) -> bool: 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( 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]: + if _character_is_dwarf(sheet) and _character_age_category(sheet) == "adult": + return _DWARF_BODY_LOCK return _AGE_BODY_LOCK[_character_age_category(sheet)] @@ -2098,7 +2156,9 @@ def _period_lock(book: str, book_profile: dict, sheet: dict) -> str: headwear = ( "Use only the explicitly established period headwear and reproduce it unchanged in every view." 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) materials = ( @@ -2134,8 +2194,8 @@ def _character_prop_item_list(sheet: dict) -> list[str]: excluded = re.compile( 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"person|people|child|baby|infant|companion|horse|battlehorse|pony|animal|creature|" - r"kind|baby|s[äa]ugling|begleiter|pferd|schlachtro(?:ss|ß)|pony|tier|kreatur|" + 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|haar|per[üu]cke|" 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|" # 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) +# 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, book_profile: dict, visual_style: 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 # words here (not just pointing at them) gives that description a # 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 = ( f"CLOTHING LOCK: this character's complete outfit in every view is exactly {established_costume} — " "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. # 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. + # 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 = ( "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 "" + ranged_weapon_lock = _character_ranged_weapon_lock(sheet) gear_condition_lock = ( "Any worn armor or equipment is clean, freshly polished, and well-kept, showing only ordinary wear from " "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 = ( 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. " @@ -2572,8 +2688,12 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, f"{authority_lock}" f"{baseline_lock}" f"{gear_condition_lock}" + f"{weapon_count_lock}" f"{weapon_handling_lock}" + f"{ranged_weapon_lock}" f"{clothing_lock}" + f"{hair_texture_lock}" + f"{female_presentation_lock}" f"{no_headwear}" ) 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)}." 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.", + hair_texture_lock.strip(), f"{ear_shape.capitalize()}." if ear_shape.startswith("pointed") else "", proportion_lock.strip() if identical_noun != "adult person" else "", "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." 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"BOOK-SOURCE EVIDENCE: {scene_evidence}. " 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." ) prop_period = ( diff --git a/routes/magazine.py b/routes/magazine.py index 17a182d..c2efacf 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -20,17 +20,19 @@ import asyncio import base64 import html import hashlib +import io import json import logging import re import statistics import time import uuid +import zipfile from pathlib import Path from typing import Any 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.config import _load_settings @@ -462,6 +464,7 @@ async def magazine_articles(doc_id: str): # generated, instead of a 404 on every article open. for a in articles: a["has_podcast"] = (d / "podcast" / f"{a['id']}.json").is_file() + a["has_audio"] = (d / "audio" / f"{a['id']}.mp3").is_file() return { "articles": articles, "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)): raise HTTPException(400, "bbox must be [x, y, width, height]") 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") 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) (d / "podcast" / f"{article_id}.json").unlink(missing_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"'}) diff --git a/static/js/magazine.js b/static/js/magazine.js index 859cb79..038c3ab 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -22,6 +22,9 @@ const state = { sentences: [], // [{ text, blockId, page, bbox }] images: [], // current article's figures, in reading order 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 playing: false, cancel: false, @@ -392,6 +395,7 @@ async function magCreate(droppedFile = null) { // completely different kind ("book", via Studio/Reader's own import) because // the magazine they had been working on appeared to have vanished. const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc'; +const MAG_CONTENTS_VIEW_KEY = 'ttsvc_mag_contents_view'; async function magOpen(docId) { try { @@ -633,37 +637,225 @@ function renderContents() { bySection.get(key).push(a); }); + const grid = state.contentsView === 'grid'; + const toggleBtn = $('mag-contents-view-toggle'); + if (toggleBtn) { + toggleBtn.innerHTML = grid + ? ' List' + : ' Thumbnails'; + toggleBtn.setAttribute('aria-pressed', String(grid)); + } + + const actionsHtml = (a, done, progress) => + '' + + (done + ? '' + : '') + + (a.has_audio + ? ' MP3' + : ''); + const selectHtml = (a) => !state.contentsSelectMode ? '' : + ''; + // 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 keeps the card's own font/wrapping + // instead of a generic text box. + const titleHtml = (a, cls) => + '' + + esc(a.title || 'Untitled') + ''; + let html = ''; bySection.forEach((arts, section) => { - html += '

' + esc(section) + '

    '; + html += '

    ' + esc(section) + '

    ' + (grid ? '
    ' : '
      '); arts.forEach((a) => { const conf = confidenceLabel(a.confidence); const done = a.status === 'approved'; const progress = (state.doc.progress || {})[a.id]; - html += '
    • ' + - '
      ' + - '' + esc(a.title || 'Untitled') + '' + - (a.author ? 'by ' + esc(a.author) + '' : '') + - '
      ' + - '
      ' + - '' + fmtPages(a.pages) + '' + - '' + fmtDuration(a.estimated_seconds) + '' + - '' + - esc(conf.badge) + '' + - '' + - (done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '' + - '
      ' + - '
      ' + - '' + - (done - ? '' - : '') + - '
    • '; + const statusBadge = '' + + (done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + ''; + if (grid) { + const firstPage = (a.pages || [])[0]; + html += '
      ' + + selectHtml(a) + + (firstPage + ? 'Page ' + firstPage + '' + : '
      ') + + titleHtml(a, 'mag-card-title') + + (a.author ? 'by ' + esc(a.author) + '' : '') + + '' + fmtPages(a.pages) + ' · ' + fmtDuration(a.estimated_seconds) + '' + + '
      ' + + esc(conf.badge) + '' + statusBadge + '
      ' + + '
      ' + actionsHtml(a, done, progress) + '
      ' + + '
      '; + } else { + html += '
    • ' + + selectHtml(a) + + '
      ' + + titleHtml(a, 'mag-toc-title') + + (a.author ? 'by ' + esc(a.author) + '' : '') + + '
      ' + + '
      ' + + '' + fmtPages(a.pages) + '' + + '' + fmtDuration(a.estimated_seconds) + '' + + '' + + esc(conf.badge) + '' + + statusBadge + + '
      ' + + '
      ' + actionsHtml(a, done, progress) + '
    • '; + } }); - html += '
    '; + html += grid ? '
' : ''; }); 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 = + '' + n + ' selected' + + '' + + '
' + + '' + + '' + + '' + + '' + + '
'; +} + +// ── 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) ───────────────────────────────────────────────────────── @@ -731,7 +923,7 @@ const MAG_REGION_TYPES = [ ['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'], ['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). const MAG_FURNITURE_KINDS = ['source', 'in_picture', 'ocr_noise', 'duplicate']; @@ -881,7 +1073,13 @@ function magReviewPageColumn(page, wrapId, interactive) { if (isImage) { const on = ownImages.has(r.id); 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 += '
' + magOrderNumber(r.id, i + 1, regions.length, interactive) + @@ -941,14 +1139,6 @@ function magReviewSetActivePage(page) { 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() { const host = $('mag-review'); const a = state.article; @@ -959,7 +1149,6 @@ function renderReview() { const conf = confidenceLabel(a.confidence); const page = state.reviewPage; const pageCount = Number(state.layout?.page_count || 0); - const pageIdx = (a?.pages || []).indexOf(page); const secondaryPage = state.reviewSpread ? magSpreadPartner(page, pageCount) : null; const spreadPages = secondaryPage != null ? [page, secondaryPage].sort((x, y) => x - y) : null; const issuePages = pageCount || '—'; @@ -968,17 +1157,14 @@ function renderReview() { let html = ''; html += '
' + - '
' + - '' + - 'Page ' + (page ?? '—') + '' + - '
' + - '
' + - '' + - 'Magazine ' + (page ?? '—') + ' / ' + issuePages + '' + - '
' + '
' + '
' + '
' + '' + - '' + - '' + - '' + - '' + - '' + + ' Draw region' + '' + @@ -1007,7 +1183,9 @@ function renderReview() { '' + - '
' + + '' + + '
' + '' + '' + '
' - : semanticLabel - ? '
Add ' + semanticLabel + '
' + - '

This region is classified as ' + semanticLabel + (state.drawKind === 'heading' ? ' and can be included in narration.' : ' and is kept out of narration.') + '

' + - '' + - '
' + - '
' : '
' + - '' + - '' + + '' + '
' + - '' + - '' + + '' + + '' + '
' + '' + '' + '
'; - wrap.appendChild(panel); +wrap.appendChild(panel); 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.left = Math.min(60, left) + '%'; - wrap.querySelectorAll('input[name="mag-draw-kind"]').forEach((r) => { - r.addEventListener('change', () => { + const kindSelect = $('mag-draw-kind'); + if (kindSelect) { + kindSelect.addEventListener('change', () => { + const isImage = kindSelect.value === 'image'; const ta = $('mag-draw-text'); - if (ta) ta.hidden = r.value === 'image' && r.checked; - $('mag-draw-exclude').hidden = r.value !== 'image'; + if (ta) ta.hidden = isImage; + $('mag-draw-exclude').hidden = !isImage; }); - }); + } } async function magReviewSubmitDraw() { @@ -1985,8 +2195,7 @@ async function magReviewSubmitDraw() { const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage); if (!box || !pageMeta) return; const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : ''; - const kind = field ? 'text' : (['heading', 'page_number', 'source', 'ad'].includes(state.drawKind) - ? state.drawKind : (document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text')); + const kind = field ? 'text' : ($('mag-draw-kind')?.value || 'text'); const text = ($('mag-draw-text')?.value || '').trim(); const bbox = [ parseFloat(box.style.left) / 100 * pageMeta.width, @@ -2024,7 +2233,9 @@ async function magReviewSubmitDraw() { try { const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions', { 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; } catch (e) { @@ -2192,8 +2403,15 @@ async function magApprove() { }); Object.assign(a, updated); 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(); - magShow('contents'); + if (nextId && state.articles.some((x) => x.id === nextId)) magReview(nextId); + else magShow('contents'); } catch (e) { 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); 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) => { const el = $(id); if (!el) return; @@ -2704,6 +2922,7 @@ async function magLoadVoices() { }); const def = state.doc && state.doc.default_voice; 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() { @@ -2880,6 +3099,10 @@ async function magOnShow() { async function magInit() { setupMagDropzone(); 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 magLoadLibrary(); magLoadVoices(); @@ -2908,15 +3131,23 @@ root.magDelete = magDelete; root.magCreate = magCreate; root.magIngest = magIngest; 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.magReviewGoMagazinePage = magReviewGoMagazinePage; -root.magReviewStepPage = magReviewStepPage; root.magReviewToggleSpread = magReviewToggleSpread; root.magReviewSetActivePage = magReviewSetActivePage; root.magReviewFocusBlock = magReviewFocusBlock; root.magReviewHighlightBox = magReviewHighlightBox; root.magReviewToggleBox = magReviewToggleBox; root.magReviewClearCurrentPage = magReviewClearCurrentPage; +root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes; root.magReocrPage = magReocrPage; root.magRefineFigures = magRefineFigures; root.magReviewToggleImage = magReviewToggleImage; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 7fce2c0..eae2ae0 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -58,6 +58,12 @@

Magazine

+ + @@ -69,6 +75,19 @@
+ +
+ + + + + +