@@ -230,6 +230,25 @@ def _chapter_has_any_section_images_to_generate(chapter) -> bool:
|
||||
return any(_section_has_image_to_generate(s) for s in chapter.sections)
|
||||
|
||||
|
||||
def _chapter_has_cover_to_generate(chapter) -> bool:
|
||||
"""章节是否有待生成的封面图(MemoirImage section_id=None 且 status 为 pending/failed)。"""
|
||||
images = getattr(chapter, "images", None) or []
|
||||
for m in images:
|
||||
if getattr(m, "section_id", None) is None:
|
||||
status = (getattr(m, "status") or "").strip()
|
||||
return status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED)
|
||||
return False
|
||||
|
||||
|
||||
def _get_cover_memoir_image(chapter):
|
||||
"""获取章节封面 MemoirImage(section_id=None),若无可生成则返回 None。"""
|
||||
images = getattr(chapter, "images", None) or []
|
||||
for m in images:
|
||||
if getattr(m, "section_id", None) is None:
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _select_placeholders_for_effective_max(
|
||||
placeholders: list[dict],
|
||||
existing_images: list[dict] | None,
|
||||
@@ -261,7 +280,7 @@ def _select_placeholders_for_effective_max(
|
||||
def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str, category: str, order_index: int, source_segments: list, user_id: str):
|
||||
"""
|
||||
将带占位符的 narrative 拆成 chapter_sections 并写入;为每段占位符创建 pending 配图。
|
||||
已有 section 与图片不删除,仅追加新内容。封面图先空着,不自动设置。
|
||||
已有 section 与图片不删除,仅追加新内容。若无封面 MemoirImage 则创建 pending 封面(section_id=None)。
|
||||
chapter 可为已有章节或 None(会新建)。返回 chapter。
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
@@ -308,6 +327,9 @@ def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str
|
||||
narrative_to_parse = (narrative or "").strip()
|
||||
order_base = 0
|
||||
|
||||
img_settings = MemoirImageSettings.from_env()
|
||||
prompt_service = MemoirImagePromptService(llm=None, settings=img_settings) if img_settings.enabled else None
|
||||
|
||||
segments = split_narrative_to_sections(narrative_to_parse)
|
||||
if not segments:
|
||||
sec = ChapterSection(
|
||||
@@ -319,14 +341,35 @@ def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str
|
||||
)
|
||||
db.add(sec)
|
||||
db.flush()
|
||||
if img_settings.enabled:
|
||||
stmt_cover = (
|
||||
select(MemoirImage)
|
||||
.where(
|
||||
MemoirImage.chapter_id == chapter.id,
|
||||
MemoirImage.section_id.is_(None),
|
||||
)
|
||||
)
|
||||
if not db.execute(stmt_cover).scalar_one_or_none():
|
||||
cover_ph = {
|
||||
"placeholder": "{{{{{{{{IMAGE:章节封面}}}}}}}}",
|
||||
"description": "章节封面",
|
||||
"index": 0,
|
||||
}
|
||||
cover_asset = build_initial_image_assets(
|
||||
[cover_ph],
|
||||
img_settings.provider,
|
||||
prompt_service.CATEGORY_STYLE_MAP.get(category, img_settings.default_style) if prompt_service else img_settings.default_style,
|
||||
img_settings.default_size,
|
||||
now_iso,
|
||||
)[0]
|
||||
cover_mi = _memoir_image_from_asset(chapter.id, None, 0, cover_asset)
|
||||
db.add(cover_mi)
|
||||
db.flush()
|
||||
chapter.title = title
|
||||
chapter.is_new = True
|
||||
chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or [])))
|
||||
return chapter
|
||||
|
||||
img_settings = MemoirImageSettings.from_env()
|
||||
prompt_service = MemoirImagePromptService(llm=None, settings=img_settings) if img_settings.enabled else None
|
||||
|
||||
# 每 3 个 section 对应 1 张图片,其他 section 的 image_id 为空
|
||||
def _should_have_image(order_idx: int) -> bool:
|
||||
return (order_idx % 3) == 2
|
||||
@@ -371,7 +414,34 @@ def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str
|
||||
db.flush()
|
||||
sec.image_id = mi.id
|
||||
db.flush()
|
||||
# 封面图先空着,不自动用首图做封面
|
||||
|
||||
# 封面图:若无则创建 pending MemoirImage(section_id=None, order_index=0)
|
||||
if img_settings.enabled:
|
||||
stmt_cover = (
|
||||
select(MemoirImage)
|
||||
.where(
|
||||
MemoirImage.chapter_id == chapter.id,
|
||||
MemoirImage.section_id.is_(None),
|
||||
)
|
||||
)
|
||||
existing_cover = db.execute(stmt_cover).scalar_one_or_none()
|
||||
if not existing_cover:
|
||||
cover_ph = {
|
||||
"placeholder": "{{{{{{{{IMAGE:章节封面}}}}}}}}",
|
||||
"description": "章节封面",
|
||||
"index": 0,
|
||||
}
|
||||
cover_asset = build_initial_image_assets(
|
||||
[cover_ph],
|
||||
img_settings.provider,
|
||||
prompt_service.CATEGORY_STYLE_MAP.get(category, img_settings.default_style) if prompt_service else img_settings.default_style,
|
||||
img_settings.default_size,
|
||||
now_iso,
|
||||
)[0]
|
||||
cover_mi = _memoir_image_from_asset(chapter.id, None, 0, cover_asset)
|
||||
db.add(cover_mi)
|
||||
db.flush()
|
||||
|
||||
chapter.title = title
|
||||
chapter.is_new = True
|
||||
chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or [])))
|
||||
@@ -611,7 +681,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
combined_text = "\n\n".join(segment_texts)
|
||||
source_ids = [seg.id for seg in category_segments]
|
||||
|
||||
# 查找 active 章节(被清除的章节不继续更新,而是创建新的),并预加载 sections
|
||||
# 查找 active 章节(被清除的章节不继续更新,而是创建新的),并预加载 sections、images
|
||||
stmt_chapter = (
|
||||
select(Chapter)
|
||||
.where(
|
||||
@@ -619,7 +689,10 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
Chapter.category == chapter_category,
|
||||
Chapter.is_active == True,
|
||||
)
|
||||
.options(joinedload(Chapter.sections))
|
||||
.options(
|
||||
joinedload(Chapter.sections).joinedload(ChapterSection.image_record),
|
||||
joinedload(Chapter.images),
|
||||
)
|
||||
)
|
||||
result_chapter = db.execute(stmt_chapter)
|
||||
chapter = result_chapter.unique().scalar_one_or_none()
|
||||
@@ -699,7 +772,10 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
)
|
||||
db.flush()
|
||||
db.refresh(chapter)
|
||||
if image_settings.enabled and _chapter_has_any_section_images_to_generate(chapter):
|
||||
if image_settings.enabled and (
|
||||
_chapter_has_any_section_images_to_generate(chapter)
|
||||
or _chapter_has_cover_to_generate(chapter)
|
||||
):
|
||||
chapters_to_enqueue.add(chapter.id)
|
||||
|
||||
# 更新 Book
|
||||
@@ -825,6 +901,16 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
|
||||
user_id=user_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(chapter)
|
||||
image_settings = MemoirImageSettings.from_env()
|
||||
if image_settings.enabled and chapter and (
|
||||
_chapter_has_any_section_images_to_generate(chapter)
|
||||
or _chapter_has_cover_to_generate(chapter)
|
||||
):
|
||||
try:
|
||||
generate_chapter_images.delay(chapter.id)
|
||||
except Exception as exc:
|
||||
logger.warning("补图任务派发失败: chapter=%s, error=%s", chapter.id, exc)
|
||||
return {"status": "success"}
|
||||
|
||||
except Exception as e:
|
||||
@@ -832,14 +918,15 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
|
||||
raise self.retry(exc=e)
|
||||
|
||||
|
||||
def build_cos_key(user_id: str, chapter_id: str, index: int, prompt: str) -> str:
|
||||
def build_cos_key(user_id: str, chapter_id: str, index: int | str, prompt: str) -> str:
|
||||
short_hash = hashlib.sha1(prompt.encode("utf-8")).hexdigest()[:10]
|
||||
return f"memoirs/{user_id}/{chapter_id}/{index}-{short_hash}.png"
|
||||
index_part = "cover" if index in (-1, "cover") else str(index)
|
||||
return f"memoirs/{user_id}/{chapter_id}/{index_part}-{short_hash}.png"
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
||||
def generate_chapter_images(self, chapter_id: str):
|
||||
"""Async task to generate images for a chapter's sections (each section has at most one image)."""
|
||||
"""Async task to generate images for a chapter's cover and sections (each section has at most one image)."""
|
||||
lock_acquired = False
|
||||
provider = None
|
||||
with get_sync_db() as db:
|
||||
@@ -860,7 +947,15 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
sections_with_pending = [
|
||||
(idx, s) for idx, s in enumerate(sections) if _section_has_image_to_generate(s)
|
||||
]
|
||||
if not sections_with_pending:
|
||||
cover_rec = _get_cover_memoir_image(chapter)
|
||||
cover_to_generate = (
|
||||
cover_rec
|
||||
if cover_rec
|
||||
and (getattr(cover_rec, "status") or "").strip()
|
||||
in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED)
|
||||
else None
|
||||
)
|
||||
if not sections_with_pending and not cover_to_generate:
|
||||
logger.info("章节补图跳过: chapter=%s, reason=no_pending_images", chapter_id)
|
||||
return {"status": "no_images"}
|
||||
|
||||
@@ -878,9 +973,10 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
image_generator = get_image_generator()
|
||||
storage = TencentCosStorageService.from_env()
|
||||
logger.info(
|
||||
"章节补图开始: chapter=%s, pending_sections=%d",
|
||||
"章节补图开始: chapter=%s, pending_sections=%d, cover=%s",
|
||||
chapter_id,
|
||||
len(sections_with_pending),
|
||||
bool(cover_to_generate),
|
||||
)
|
||||
retryable_failures: list[str] = []
|
||||
permanent_failures: list[str] = []
|
||||
@@ -899,6 +995,69 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
rec.retryable = d.get("retryable")
|
||||
rec.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# 先处理封面图
|
||||
if cover_to_generate:
|
||||
current_item = memoir_image_to_dict(cover_to_generate) or {}
|
||||
current_item.setdefault("placeholder", "")
|
||||
current_item.setdefault("description", "")
|
||||
current_item["status"] = IMAGE_STATUS_PROCESSING
|
||||
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
_apply_item_to_memoir_image(cover_to_generate, current_item)
|
||||
db.commit()
|
||||
try:
|
||||
sections_ordered = sorted(sections, key=lambda s: getattr(s, "order_index", 0))
|
||||
first_content = (sections_ordered[0].content or "").strip() if sections_ordered else ""
|
||||
context_excerpt = " ".join(first_content.split("\n")[:5])[:200]
|
||||
prompt_data = prompt_service.build_cover_prompt(
|
||||
chapter_title=chapter.title,
|
||||
chapter_category=chapter.category or "",
|
||||
context_excerpt=context_excerpt,
|
||||
)
|
||||
result = image_generator.generate(
|
||||
prompt_data["prompt"],
|
||||
prompt_data["size"],
|
||||
prompt_data["style"],
|
||||
)
|
||||
if result.status != TaskStatus.COMPLETED or not result.image_url:
|
||||
raise RuntimeError(result.error or "Image generation failed")
|
||||
image_bytes = _normalize_image_bytes_for_storage(
|
||||
image_generator.download_image(result.image_url)
|
||||
)
|
||||
key = build_cos_key(chapter.user_id, chapter.id, "cover", prompt_data["prompt"])
|
||||
current_item["storage_key"] = key
|
||||
current_item["url"] = storage.upload_bytes(image_bytes, key, "image/png")
|
||||
current_item["prompt"] = prompt_data["prompt"]
|
||||
current_item["style"] = prompt_data["style"]
|
||||
current_item["size"] = prompt_data["size"]
|
||||
current_item["status"] = IMAGE_STATUS_COMPLETED
|
||||
current_item["error"] = None
|
||||
current_item["retryable"] = None
|
||||
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
_apply_item_to_memoir_image(cover_to_generate, current_item)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"章节封面图生成成功: chapter=%s, url=%s",
|
||||
chapter_id,
|
||||
current_item["url"],
|
||||
)
|
||||
except Exception as exc:
|
||||
failure_msg = f"cover, error={exc}"
|
||||
if isinstance(exc, CosUploadError) and not exc.retryable:
|
||||
permanent_failures.append(failure_msg)
|
||||
logger.error("封面图上传不可重试,清理: chapter=%s, %s", chapter_id, failure_msg)
|
||||
db.delete(cover_to_generate)
|
||||
db.commit()
|
||||
else:
|
||||
current_item = memoir_image_to_dict(cover_to_generate) or {}
|
||||
current_item["status"] = IMAGE_STATUS_FAILED
|
||||
current_item["error"] = str(exc)
|
||||
current_item["retryable"] = True
|
||||
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
retryable_failures.append(failure_msg)
|
||||
logger.warning("封面图生成失败(可重试): chapter=%s, %s", chapter_id, failure_msg)
|
||||
_apply_item_to_memoir_image(cover_to_generate, current_item)
|
||||
db.commit()
|
||||
|
||||
for sec_index, section in sections_with_pending:
|
||||
item = memoir_image_to_dict(section.image_record) if section.image_record else {}
|
||||
current_item = dict(item) if item else {}
|
||||
@@ -966,7 +1125,6 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
_apply_item_to_memoir_image(section.image_record, current_item)
|
||||
db.commit()
|
||||
|
||||
# 封面图先空着,不自动用首张完成图做封面
|
||||
if retryable_failures:
|
||||
raise RuntimeError(
|
||||
f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_failures)}"
|
||||
|
||||
Reference in New Issue
Block a user