"""章节封面是否可入队(与 Celery 任务共享,避免循环 import)。""" from __future__ import annotations from typing import Any from app.core.config import settings from app.features.memoir.asset_resolver import ( parse_asset_refs, strip_image_placeholders, ) from app.features.memoir.memoir_images.schema import ( IMAGE_STATUS_FAILED, IMAGE_STATUS_PENDING, ) def chapter_has_story_links(chapter: Any) -> bool: return any( getattr(link, "story", None) for link in getattr(chapter, "story_links", None) or [] ) def effective_chapter_markdown_for_cover_gates(chapter: Any) -> str: """ 用于封面闸门计数:优先 DB canonical;若为空且已挂 stories,则用内存物化串 (与列表/详情在 compose_dirty 时的临时正文对齐,避免「有图但 canonical 未落库」导致永不出封面)。 """ md = (getattr(chapter, "canonical_markdown", None) or "").strip() if md: return md if chapter_has_story_links(chapter): from app.features.memoir.chapter_markdown_compose import ( materialize_chapter_markdown_from_loaded_chapter, ) try: alt = ( materialize_chapter_markdown_from_loaded_chapter(chapter) or "" ).strip() except Exception: return "" return alt return "" def count_chapter_inline_body_images( chapter: Any, *, markdown: str | None = None ) -> int: """统计 asset:// 插图次数;未传 markdown 时用 effective_chapter_markdown_for_cover_gates。""" source = ( markdown if markdown is not None else effective_chapter_markdown_for_cover_gates(chapter) ) return len(parse_asset_refs(source)) def chapter_eligible_for_cover_by_inline_body_image_count( chapter: Any, *, markdown: str | None = None ) -> bool: """正文内 asset:// 数量 ≥ 配置阈值时允许封面;markdown 非 None 时仅用该串计数。""" min_required = int(settings.memoir_min_inline_images_for_chapter_cover) return count_chapter_inline_body_images(chapter, markdown=markdown) >= min_required def primary_chapter_memoir_image(chapter: Any) -> Any | None: """章节级 MemoirImage(封面槽位):按 order_index 最小取第一条。""" imgs = sorted( getattr(chapter, "images", None) or [], key=lambda m: getattr(m, "order_index", 0), ) return imgs[0] if imgs else None def chapter_needs_cover_enqueue(chapter) -> bool: """尚无 cover_asset、有正文、且正文内 asset 插图达到 env 阈值时,可派发 generate_chapter_cover。""" if not chapter: return False if not chapter_has_story_links(chapter): return False if getattr(chapter, "cover_asset_id", None): return False view = effective_chapter_markdown_for_cover_gates(chapter) body = strip_image_placeholders(view).strip() if not body: return False return chapter_eligible_for_cover_by_inline_body_image_count(chapter, markdown=view) def chapter_has_cover_to_generate(chapter) -> bool: """章节是否有待生成的封面图(任一条 chapter 级 MemoirImage 为 pending/failed)。""" for m in getattr(chapter, "images", None) or []: status = (m.status or "").strip() if status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED): return True return False def cover_memoir_image_pending_or_failed(chapter: Any) -> Any | None: """用于补图任务:按 order_index 找到第一条 pending/failed 的章节配图行。""" images = sorted( getattr(chapter, "images", None) or [], key=lambda m: getattr(m, "order_index", 0), ) for m in images: st = (m.status or "").strip() if st in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED): return m return None