2026-03-20 15:15:35 +08:00
|
|
|
|
"""章节封面是否可入队(与 Celery 任务共享,避免循环 import)。"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
2026-04-16 20:42:54 +08:00
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
from app.features.memoir.asset_resolver import (
|
|
|
|
|
|
parse_asset_refs,
|
|
|
|
|
|
strip_image_placeholders,
|
|
|
|
|
|
)
|
2026-03-20 15:15:35 +08:00
|
|
|
|
from app.features.memoir.memoir_images.schema import (
|
|
|
|
|
|
IMAGE_STATUS_FAILED,
|
|
|
|
|
|
IMAGE_STATUS_PENDING,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
|
2026-03-22 16:45:57 +08:00
|
|
|
|
def chapter_has_story_links(chapter: Any) -> bool:
|
|
|
|
|
|
return any(
|
|
|
|
|
|
getattr(link, "story", None)
|
|
|
|
|
|
for link in getattr(chapter, "story_links", None) or []
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 20:42:54 +08:00
|
|
|
|
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))
|
2026-03-20 16:36:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 20:42:54 +08:00
|
|
|
|
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
|
2026-03-20 16:36:42 +08:00
|
|
|
|
|
2026-03-20 15:15:35 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-04-16 20:42:54 +08:00
|
|
|
|
"""尚无 cover_asset、有正文、且正文内 asset 插图达到 env 阈值时,可派发 generate_chapter_cover。"""
|
2026-03-20 15:15:35 +08:00
|
|
|
|
if not chapter:
|
|
|
|
|
|
return False
|
2026-03-22 16:45:57 +08:00
|
|
|
|
if not chapter_has_story_links(chapter):
|
|
|
|
|
|
return False
|
2026-03-20 15:15:35 +08:00
|
|
|
|
if getattr(chapter, "cover_asset_id", None):
|
|
|
|
|
|
return False
|
2026-04-16 20:42:54 +08:00
|
|
|
|
view = effective_chapter_markdown_for_cover_gates(chapter)
|
|
|
|
|
|
body = strip_image_placeholders(view).strip()
|
|
|
|
|
|
if not body:
|
2026-03-20 16:36:42 +08:00
|
|
|
|
return False
|
2026-04-16 20:42:54 +08:00
|
|
|
|
return chapter_eligible_for_cover_by_inline_body_image_count(chapter, markdown=view)
|
2026-03-20 15:15:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def chapter_has_cover_to_generate(chapter) -> bool:
|
|
|
|
|
|
"""章节是否有待生成的封面图(任一条 chapter 级 MemoirImage 为 pending/failed)。"""
|
|
|
|
|
|
for m in getattr(chapter, "images", None) or []:
|
2026-03-22 16:45:57 +08:00
|
|
|
|
status = (m.status or "").strip()
|
2026-03-20 15:15:35 +08:00
|
|
|
|
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:
|
2026-03-22 16:45:57 +08:00
|
|
|
|
st = (m.status or "").strip()
|
2026-03-20 15:15:35 +08:00
|
|
|
|
if st in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED):
|
|
|
|
|
|
return m
|
|
|
|
|
|
return None
|