mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
feat: unified conversion planner (build_conversion_plan)
Pure function that takes ConversionRequest -> ConversionPlan. Handles chapter parsing, voice markers, chunks, intro/outro, output layout. Replaces duplicated planning logic in both PyQt and WebUI runners.
This commit is contained in:
@@ -0,0 +1,400 @@
|
|||||||
|
"""Unified conversion planner.
|
||||||
|
|
||||||
|
Pure functions that take a ConversionRequest and produce a ConversionPlan.
|
||||||
|
No side effects, no I/O — all complexity from both UIs in one place.
|
||||||
|
|
||||||
|
This is Stage 2 of the conversion flow unification plan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from abogen.application.conversion_models import (
|
||||||
|
ChapterPlan,
|
||||||
|
ConversionPlan,
|
||||||
|
IntroOutroSpec,
|
||||||
|
OutputLayout,
|
||||||
|
SegmentPlan,
|
||||||
|
)
|
||||||
|
from abogen.application.conversion_request import ConversionRequest
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
from abogen.domain.file_type import auto_select_relevant_chapters
|
||||||
|
from abogen.domain.intro_outro import resolve_intro, resolve_outro
|
||||||
|
from abogen.domain.metadata_extraction import extract_metadata_for_file
|
||||||
|
from abogen.domain.metadata_merge import merge_metadata
|
||||||
|
from abogen.domain.output_paths import (
|
||||||
|
resolve_output_directory,
|
||||||
|
resolve_project_layout,
|
||||||
|
resolve_unique_path,
|
||||||
|
sanitize_output_stem,
|
||||||
|
)
|
||||||
|
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||||
|
|
||||||
|
|
||||||
|
def build_conversion_plan(request: ConversionRequest) -> ConversionPlan:
|
||||||
|
"""Build a complete conversion plan from a request.
|
||||||
|
|
||||||
|
This is the single entry point that both UIs will call.
|
||||||
|
It handles all the planning logic that was previously duplicated
|
||||||
|
in both PyQt and WebUI conversion runners.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Normalized conversion request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ConversionPlan with all chapters, segments, and output layout
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If request is invalid (no source, no chapters, etc.)
|
||||||
|
"""
|
||||||
|
# 1. Extract and validate source
|
||||||
|
source_text = _extract_source_text(request)
|
||||||
|
if not source_text or not source_text.strip():
|
||||||
|
raise ValueError("No text content to convert")
|
||||||
|
|
||||||
|
# 2. Extract metadata
|
||||||
|
metadata = _extract_metadata(request)
|
||||||
|
|
||||||
|
# 3. Parse chapters
|
||||||
|
raw_chapters = _parse_chapters(source_text, request)
|
||||||
|
|
||||||
|
# 4. Apply chapter selection/overrides
|
||||||
|
selected_chapters = _apply_selection(raw_chapters, request)
|
||||||
|
|
||||||
|
# 5. Build segments for each chapter
|
||||||
|
chapters = _build_chapters(selected_chapters, request)
|
||||||
|
|
||||||
|
# 6. Build intro/outro
|
||||||
|
intro, outro = _build_intro_outro(metadata, request)
|
||||||
|
|
||||||
|
# 7. Resolve output layout
|
||||||
|
output_layout = _resolve_output_layout(request)
|
||||||
|
|
||||||
|
return ConversionPlan(
|
||||||
|
request=request,
|
||||||
|
metadata=metadata,
|
||||||
|
chapters=chapters,
|
||||||
|
intro=intro,
|
||||||
|
outro=outro,
|
||||||
|
output_layout=output_layout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_source_text(request: ConversionRequest) -> Optional[str]:
|
||||||
|
"""Extract text from request source."""
|
||||||
|
if request.direct_text:
|
||||||
|
return request.direct_text
|
||||||
|
if request.source_path and request.source_path.exists():
|
||||||
|
from abogen.subtitle_utils import clean_text
|
||||||
|
|
||||||
|
encoding = "utf-8"
|
||||||
|
try:
|
||||||
|
with open(request.source_path, "r", encoding=encoding, errors="replace") as f:
|
||||||
|
text = f.read()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return clean_text(text)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_metadata(request: ConversionRequest) -> Dict[str, Any]:
|
||||||
|
"""Extract metadata from source file."""
|
||||||
|
if request.direct_text:
|
||||||
|
return dict(request.metadata_tags)
|
||||||
|
|
||||||
|
if request.source_path and request.source_path.exists():
|
||||||
|
try:
|
||||||
|
extraction = extract_metadata_for_file(
|
||||||
|
str(request.source_path), is_direct_text=False
|
||||||
|
)
|
||||||
|
metadata = dict(extraction.metadata) if extraction.metadata else {}
|
||||||
|
except Exception:
|
||||||
|
metadata = {}
|
||||||
|
metadata = merge_metadata(metadata, request.metadata_tags)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
return dict(request.metadata_tags)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_chapters(
|
||||||
|
source_text: str, request: ConversionRequest
|
||||||
|
) -> List[Tuple[str, str, str]]:
|
||||||
|
"""Parse source text into raw chapters.
|
||||||
|
|
||||||
|
Returns list of (title, body_text, default_voice) tuples.
|
||||||
|
"""
|
||||||
|
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||||
|
|
||||||
|
# For direct text, use the text as-is
|
||||||
|
if request.direct_text:
|
||||||
|
chapters = parse_chapters_from_text(source_text, default_title="text", clean=False)
|
||||||
|
else:
|
||||||
|
chapters = parse_chapters_from_text(source_text, default_title="text", clean=False)
|
||||||
|
|
||||||
|
# Default voice from request
|
||||||
|
default_voice = request.voice or "M1"
|
||||||
|
|
||||||
|
return [(title, text, default_voice) for title, text in chapters]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_selection(
|
||||||
|
raw_chapters: List[Tuple[str, str, str]], request: ConversionRequest
|
||||||
|
) -> List[Tuple[str, str, str]]:
|
||||||
|
"""Apply chapter selection and overrides."""
|
||||||
|
from abogen.text_extractor import ExtractedChapter
|
||||||
|
|
||||||
|
# Convert to ExtractedChapter objects for auto_select_relevant_chapters
|
||||||
|
extracted = [
|
||||||
|
ExtractedChapter(title=title, text=text)
|
||||||
|
for title, text, _ in raw_chapters
|
||||||
|
]
|
||||||
|
|
||||||
|
# If user specified chapters, apply overrides
|
||||||
|
if request.chapter_overrides:
|
||||||
|
selected, _, diagnostics = apply_chapter_overrides(extracted, request.chapter_overrides)
|
||||||
|
if selected:
|
||||||
|
# Map back to (title, text, voice) tuples
|
||||||
|
result = []
|
||||||
|
for ch in selected:
|
||||||
|
# Find matching original chapter to get voice
|
||||||
|
voice = request.voice or "M1"
|
||||||
|
for orig_title, orig_text, orig_voice in raw_chapters:
|
||||||
|
if orig_title == ch.title:
|
||||||
|
voice = orig_voice
|
||||||
|
break
|
||||||
|
result.append((ch.title, ch.text or "", voice))
|
||||||
|
return result
|
||||||
|
# If no chapters selected, fall through to auto-selection
|
||||||
|
|
||||||
|
# Auto-select relevant chapters
|
||||||
|
from abogen.domain.file_type import infer_file_type
|
||||||
|
|
||||||
|
file_type = infer_file_type(request.source_path) if request.source_path else "text"
|
||||||
|
result = auto_select_relevant_chapters(extracted, file_type)
|
||||||
|
filtered = result.kept
|
||||||
|
|
||||||
|
if filtered:
|
||||||
|
# Map back to (title, text, voice) tuples
|
||||||
|
result = []
|
||||||
|
for ch in filtered:
|
||||||
|
voice = request.voice or "M1"
|
||||||
|
for orig_title, orig_text, orig_voice in raw_chapters:
|
||||||
|
if orig_title == ch.title:
|
||||||
|
voice = orig_voice
|
||||||
|
break
|
||||||
|
result.append((ch.title, ch.text or "", voice))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Fall back to all chapters
|
||||||
|
return raw_chapters
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chapters(
|
||||||
|
selected_chapters: List[Tuple[str, str, str]], request: ConversionRequest
|
||||||
|
) -> List[ChapterPlan]:
|
||||||
|
"""Build ChapterPlan with SegmentPlan for each chapter."""
|
||||||
|
chapters = []
|
||||||
|
|
||||||
|
for idx, (title, body_text, default_voice) in enumerate(selected_chapters, 1):
|
||||||
|
# Build segments for this chapter
|
||||||
|
segments = _build_segments(body_text, default_voice, request)
|
||||||
|
|
||||||
|
chapter = ChapterPlan(
|
||||||
|
index=idx,
|
||||||
|
title=title,
|
||||||
|
original_title=title,
|
||||||
|
body_text=body_text,
|
||||||
|
segments=segments,
|
||||||
|
voice_spec=default_voice,
|
||||||
|
)
|
||||||
|
chapters.append(chapter)
|
||||||
|
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
def _build_segments(
|
||||||
|
body_text: str, default_voice: str, request: ConversionRequest
|
||||||
|
) -> List[SegmentPlan]:
|
||||||
|
"""Build SegmentPlan list for a chapter's body text.
|
||||||
|
|
||||||
|
Handles voice markers (PyQt) and chunks (WebUI).
|
||||||
|
"""
|
||||||
|
segments = []
|
||||||
|
|
||||||
|
# Check for chunks (WebUI style)
|
||||||
|
if request.chunks:
|
||||||
|
# Group chunks by chapter (simplified — assume chunks are for current chapter)
|
||||||
|
for chunk_idx, chunk in enumerate(request.chunks):
|
||||||
|
chunk_text = chunk.get("normalized_text") or chunk.get("text", "")
|
||||||
|
if not chunk_text or not chunk_text.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
chunk_voice = _resolve_chunk_voice(chunk, default_voice, request)
|
||||||
|
speaker_id = chunk.get("speaker_id", "narrator")
|
||||||
|
|
||||||
|
segments.append(
|
||||||
|
SegmentPlan(
|
||||||
|
text=chunk_text.strip(),
|
||||||
|
voice_spec=chunk_voice,
|
||||||
|
kind="body",
|
||||||
|
speaker_id=speaker_id,
|
||||||
|
chunk_id=chunk.get("id"),
|
||||||
|
chunk_index=chunk.get("chunk_index", chunk_idx),
|
||||||
|
level=chunk.get("level", request.chunk_level),
|
||||||
|
source="chunk",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
# Check for voice markers (PyQt style)
|
||||||
|
# Detect markers even if validation fails (voice names may not be loaded yet)
|
||||||
|
from abogen.subtitle_utils import _VOICE_MARKER_SEARCH_PATTERN
|
||||||
|
|
||||||
|
has_voice_markers = bool(_VOICE_MARKER_SEARCH_PATTERN.search(body_text))
|
||||||
|
voice_segments, last_voice, valid_count, invalid_count = split_text_by_voice_markers(
|
||||||
|
body_text, default_voice
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_voice_markers or (len(voice_segments) > 1):
|
||||||
|
# Voice markers were used
|
||||||
|
for voice_name, segment_text in voice_segments:
|
||||||
|
if not segment_text or not segment_text.strip():
|
||||||
|
continue
|
||||||
|
segments.append(
|
||||||
|
SegmentPlan(
|
||||||
|
text=segment_text.strip(),
|
||||||
|
voice_spec=voice_name,
|
||||||
|
kind="body",
|
||||||
|
source="voice_marker",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
# No voice markers — single segment for entire body
|
||||||
|
if body_text and body_text.strip():
|
||||||
|
segments.append(
|
||||||
|
SegmentPlan(
|
||||||
|
text=body_text.strip(),
|
||||||
|
voice_spec=default_voice,
|
||||||
|
kind="body",
|
||||||
|
source="chapter",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_chunk_voice(
|
||||||
|
chunk: Dict[str, Any], default_voice: str, request: ConversionRequest
|
||||||
|
) -> str:
|
||||||
|
"""Resolve voice for a chunk."""
|
||||||
|
# Check for speaker-based voice
|
||||||
|
speaker_id = chunk.get("speaker_id", "narrator")
|
||||||
|
if speaker_id and speaker_id != "narrator" and request.speakers:
|
||||||
|
speaker_config = request.speakers.get(speaker_id, {})
|
||||||
|
if isinstance(speaker_config, dict):
|
||||||
|
voice = speaker_config.get("voice")
|
||||||
|
if voice:
|
||||||
|
return voice
|
||||||
|
|
||||||
|
# Check for direct voice field
|
||||||
|
voice = chunk.get("voice")
|
||||||
|
if voice:
|
||||||
|
return voice
|
||||||
|
|
||||||
|
return default_voice
|
||||||
|
|
||||||
|
|
||||||
|
def _build_intro_outro(
|
||||||
|
metadata: Dict[str, Any], request: ConversionRequest
|
||||||
|
) -> Tuple[Optional[IntroOutroSpec], Optional[IntroOutroSpec]]:
|
||||||
|
"""Build intro and outro specs."""
|
||||||
|
intro_spec = None
|
||||||
|
outro_spec = None
|
||||||
|
|
||||||
|
# Intro
|
||||||
|
if request.read_title_intro:
|
||||||
|
resolved = resolve_intro(
|
||||||
|
metadata,
|
||||||
|
request.original_filename,
|
||||||
|
True,
|
||||||
|
request.voice or "M1",
|
||||||
|
request.voice or "M1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
if resolved.enabled:
|
||||||
|
intro_spec = IntroOutroSpec(
|
||||||
|
enabled=True,
|
||||||
|
text=resolved.text,
|
||||||
|
voice_spec=resolved.voice_spec,
|
||||||
|
kind="intro",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Outro
|
||||||
|
if request.read_closing_outro:
|
||||||
|
resolved = resolve_outro(
|
||||||
|
metadata,
|
||||||
|
request.original_filename,
|
||||||
|
True,
|
||||||
|
request.voice or "M1",
|
||||||
|
request.voice or "M1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
if resolved.enabled:
|
||||||
|
outro_spec = IntroOutroSpec(
|
||||||
|
enabled=True,
|
||||||
|
text=resolved.text,
|
||||||
|
voice_spec=resolved.voice_spec,
|
||||||
|
kind="outro",
|
||||||
|
)
|
||||||
|
|
||||||
|
return intro_spec, outro_spec
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_output_layout(request: ConversionRequest) -> OutputLayout:
|
||||||
|
"""Resolve output paths for the conversion."""
|
||||||
|
# Determine base output directory
|
||||||
|
if request.save_mode == "custom_folder" and request.output_folder:
|
||||||
|
parent_dir = Path(request.output_folder)
|
||||||
|
elif request.source_path:
|
||||||
|
parent_dir = request.source_path.parent
|
||||||
|
else:
|
||||||
|
parent_dir = Path.cwd()
|
||||||
|
|
||||||
|
# Determine base name
|
||||||
|
if request.original_filename:
|
||||||
|
base_name = sanitize_output_stem(request.original_filename)
|
||||||
|
elif request.source_path:
|
||||||
|
base_name = sanitize_output_stem(request.source_path.stem)
|
||||||
|
else:
|
||||||
|
base_name = "output"
|
||||||
|
|
||||||
|
# Find unique output path
|
||||||
|
allowed_exts = {request.output_format, "srt", "ass", "vtt", "mp4", "m4b"}
|
||||||
|
unique_base = resolve_unique_path(
|
||||||
|
parent_dir, base_name, "", allowed_extensions=allowed_exts
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve project layout
|
||||||
|
project_root = None
|
||||||
|
audio_dir = parent_dir
|
||||||
|
subtitle_dir = None
|
||||||
|
metadata_dir = None
|
||||||
|
|
||||||
|
if request.save_as_project:
|
||||||
|
project_root, audio_dir, subtitle_dir, metadata_dir = resolve_project_layout(
|
||||||
|
original_filename=request.original_filename,
|
||||||
|
save_as_project=True,
|
||||||
|
base_dir=parent_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
return OutputLayout(
|
||||||
|
parent_dir=parent_dir,
|
||||||
|
project_root=project_root,
|
||||||
|
audio_dir=audio_dir,
|
||||||
|
subtitle_dir=subtitle_dir,
|
||||||
|
metadata_dir=metadata_dir,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user