455 lines
16 KiB
Python
455 lines
16 KiB
Python
"""
|
||
Story 主插图生成 Celery 任务。
|
||
|
||
从 story_image_intents 原子 claim intent,生成图片,写入 assets,更新 intent。
|
||
不读取正文占位符。
|
||
"""
|
||
|
||
import hashlib
|
||
import time
|
||
import uuid
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from celery import shared_task
|
||
from PIL import Image
|
||
from sqlalchemy import and_, func, or_, select, update
|
||
|
||
from app.agents.image_prompt import get_image_prompt_orchestrator
|
||
from app.core.db import get_sync_db
|
||
from app.core.dependencies import get_image_generator
|
||
from app.core.logging import get_logger
|
||
from app.core.memoir_pipeline_progress import merge_fanout_item
|
||
from app.core.redis_lock import acquire_redis_lock, release_redis_lock
|
||
from app.features.asset.models import Asset
|
||
from app.features.memoir.asset_resolver import strip_asset_image_refs_from_markdown
|
||
from app.features.memoir.memoir_images.settings import MemoirImageSettings
|
||
from app.features.memoir.memoir_images.storage import TencentCosStorageService
|
||
from app.features.story.backfill import backfill_image_into_markdown
|
||
from app.features.story.models import Story, StoryImageIntent, StoryVersion
|
||
from app.ports.image_gen import TaskStatus
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
STORY_IMAGE_LOCK_TTL_SECONDS = 1800
|
||
STORY_IMAGE_CLAIM_TTL_SECONDS = 1800
|
||
|
||
|
||
def _enqueue_chapter_effects_after_image_backfill(story_id: str) -> None:
|
||
"""主图回填后标记关联章节 dirty,并经统一 post-commit 入口派发章节物化与 compaction。"""
|
||
try:
|
||
with get_sync_db() as session:
|
||
from app.features.memoir import repo as memoir_repo
|
||
|
||
story = session.get(Story, story_id)
|
||
if not story:
|
||
return
|
||
uid = str(story.user_id)
|
||
memoir_repo.mark_chapters_dirty_for_story_sync(session, story_id)
|
||
chapter_ids = memoir_repo.get_chapter_ids_linked_to_story_sync(
|
||
session, story_id
|
||
)
|
||
session.commit()
|
||
user_id = uid
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"mark_chapters_dirty_for_story_sync failed story={}: {}", story_id, exc
|
||
)
|
||
return
|
||
from app.features.story.post_commit import enqueue_story_post_commit_effects
|
||
|
||
enqueue_story_post_commit_effects(
|
||
user_id=user_id,
|
||
story_ids=set(),
|
||
chapter_ids=set(chapter_ids),
|
||
trigger_source="story_image_backfill",
|
||
need_image=False,
|
||
need_recompose=True,
|
||
need_compaction=True,
|
||
)
|
||
|
||
|
||
def _build_story_image_cos_key(
|
||
user_id: str, story_id: str, intent_id: str, prompt: str
|
||
) -> str:
|
||
short_hash = hashlib.sha1(prompt.encode("utf-8")).hexdigest()[:10]
|
||
return f"stories/{user_id}/{story_id}/{intent_id}-{short_hash}.png"
|
||
|
||
|
||
def _normalize_image_bytes(image_bytes: bytes) -> bytes:
|
||
from io import BytesIO
|
||
|
||
with Image.open(BytesIO(image_bytes)) as image:
|
||
output = BytesIO()
|
||
if image.mode in {"RGBA", "LA"}:
|
||
normalized = image
|
||
elif image.mode == "P":
|
||
normalized = image.convert("RGBA")
|
||
else:
|
||
normalized = image.convert("RGB")
|
||
normalized.save(output, format="PNG")
|
||
return output.getvalue()
|
||
|
||
|
||
def _story_image_claimable_clause(now: datetime):
|
||
cutoff = now - timedelta(seconds=STORY_IMAGE_CLAIM_TTL_SECONDS)
|
||
return or_(
|
||
StoryImageIntent.status.in_(["pending", "failed"]),
|
||
and_(
|
||
StoryImageIntent.status == "processing",
|
||
or_(
|
||
StoryImageIntent.claimed_at.is_(None),
|
||
StoryImageIntent.claimed_at < cutoff,
|
||
),
|
||
),
|
||
)
|
||
|
||
|
||
def _claim_story_image_intent_sync(db, story_id: str, claim_token: str):
|
||
now = datetime.now(timezone.utc)
|
||
claimable = _story_image_claimable_clause(now)
|
||
candidate_id = db.execute(
|
||
select(StoryImageIntent.id)
|
||
.where(StoryImageIntent.story_id == story_id)
|
||
.where(StoryImageIntent.intent_role == "primary")
|
||
.where(claimable)
|
||
.order_by(
|
||
StoryImageIntent.updated_at.desc(), StoryImageIntent.created_at.desc()
|
||
)
|
||
.limit(1)
|
||
).scalar_one_or_none()
|
||
if not candidate_id:
|
||
return None
|
||
|
||
claimed = db.execute(
|
||
update(StoryImageIntent)
|
||
.where(StoryImageIntent.id == candidate_id)
|
||
.where(_story_image_claimable_clause(now))
|
||
.values(
|
||
status="processing",
|
||
claim_token=claim_token,
|
||
claimed_at=now,
|
||
updated_at=now,
|
||
error=None,
|
||
attempt_count=func.coalesce(StoryImageIntent.attempt_count, 0) + 1,
|
||
)
|
||
)
|
||
if (claimed.rowcount or 0) != 1:
|
||
db.rollback()
|
||
return None
|
||
|
||
row = (
|
||
db.execute(
|
||
select(StoryImageIntent, Story)
|
||
.join(Story, StoryImageIntent.story_id == Story.id)
|
||
.where(StoryImageIntent.id == candidate_id)
|
||
)
|
||
.unique()
|
||
.first()
|
||
)
|
||
db.commit()
|
||
return row
|
||
|
||
|
||
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
||
def generate_story_image(self, story_id: str, memoir_correlation_id: str | None = None):
|
||
"""
|
||
为 story 生成主插图。
|
||
从 story_image_intents 原子认领 primary intent,生成后写入 assets 并更新 intent。
|
||
"""
|
||
celery_tid = str(self.request.id)
|
||
t0 = time.perf_counter()
|
||
logger.info(
|
||
"event=story_image_task_start story_id={} task_id={} msg=故事主图生成任务开始",
|
||
story_id,
|
||
celery_tid,
|
||
)
|
||
lock_key = f"lock:story-image:{story_id}"
|
||
lock_handle = acquire_redis_lock(lock_key, ttl_seconds=STORY_IMAGE_LOCK_TTL_SECONDS)
|
||
if lock_handle is None:
|
||
logger.debug("generate_story_image: story={}, reason=locked", story_id)
|
||
merge_fanout_item(
|
||
memoir_correlation_id,
|
||
list_name="story_images",
|
||
id_field="story_id",
|
||
item_id=story_id,
|
||
task_id=celery_tid,
|
||
status="locked",
|
||
)
|
||
return {"status": "locked"}
|
||
|
||
claim_token = uuid.uuid4().hex
|
||
intent = None
|
||
story = None
|
||
try:
|
||
with get_sync_db() as db:
|
||
row = _claim_story_image_intent_sync(db, story_id, claim_token)
|
||
if not row:
|
||
logger.debug(
|
||
"generate_story_image: story={}, reason=no_claimable_intent",
|
||
story_id,
|
||
)
|
||
merge_fanout_item(
|
||
memoir_correlation_id,
|
||
list_name="story_images",
|
||
id_field="story_id",
|
||
item_id=story_id,
|
||
task_id=celery_tid,
|
||
status="no_intent",
|
||
)
|
||
return {"status": "no_intent"}
|
||
|
||
intent, story = row
|
||
|
||
img_cfg = MemoirImageSettings.from_env()
|
||
min_body = img_cfg.story_image_min_body_chars
|
||
if min_body > 0:
|
||
plain = strip_asset_image_refs_from_markdown(
|
||
story.canonical_markdown or ""
|
||
).strip()
|
||
if len(plain) < min_body:
|
||
with get_sync_db() as db:
|
||
intent_db = db.get(StoryImageIntent, intent.id)
|
||
if intent_db and (intent_db.status or "").strip() == "processing":
|
||
intent_db.status = "skipped"
|
||
intent_db.error = f"body_below_min_chars:{len(plain)}"
|
||
intent_db.claim_token = None
|
||
intent_db.claimed_at = None
|
||
intent_db.updated_at = datetime.now(timezone.utc)
|
||
db.commit()
|
||
logger.info(
|
||
"generate_story_image: skipped body too short story={} len={} min={}",
|
||
story_id,
|
||
len(plain),
|
||
min_body,
|
||
)
|
||
merge_fanout_item(
|
||
memoir_correlation_id,
|
||
list_name="story_images",
|
||
id_field="story_id",
|
||
item_id=story_id,
|
||
task_id=celery_tid,
|
||
status="skipped_body_too_short",
|
||
)
|
||
return {"status": "skipped_body_too_short"}
|
||
|
||
merge_fanout_item(
|
||
memoir_correlation_id,
|
||
list_name="story_images",
|
||
id_field="story_id",
|
||
item_id=story_id,
|
||
task_id=celery_tid,
|
||
status="running",
|
||
)
|
||
|
||
image_generator = get_image_generator()
|
||
storage = TencentCosStorageService.from_env()
|
||
|
||
settings = img_cfg
|
||
orch = get_image_prompt_orchestrator()
|
||
intent_style = (getattr(intent, "style_profile", None) or "").strip() or None
|
||
prompt_out = orch.build_story_primary_prompt(
|
||
story_title=story.title or "",
|
||
story_stage=story.stage,
|
||
prompt_brief=intent.prompt_brief or "",
|
||
style_profile=intent_style,
|
||
)
|
||
prompt_final = prompt_out["prompt"]
|
||
style_for_image = (
|
||
(prompt_out.get("style") or "").strip()
|
||
or intent_style
|
||
or settings.default_style
|
||
)
|
||
result = image_generator.generate(
|
||
prompt_final,
|
||
prompt_out.get("size") or settings.default_size,
|
||
style_for_image,
|
||
)
|
||
if result.status != TaskStatus.COMPLETED or not result.image_url:
|
||
raise RuntimeError(result.error or "Image generation failed")
|
||
|
||
image_bytes = _normalize_image_bytes(
|
||
image_generator.download_image(result.image_url)
|
||
)
|
||
cos_key = _build_story_image_cos_key(
|
||
story.user_id, story_id, intent.id, prompt_final
|
||
)
|
||
url = storage.upload_bytes(image_bytes, cos_key, "image/png")
|
||
|
||
asset_id = str(uuid.uuid4())
|
||
with get_sync_db() as db:
|
||
intent_db = db.get(StoryImageIntent, intent.id)
|
||
if (
|
||
not intent_db
|
||
or (intent_db.status or "").strip() != "processing"
|
||
or (intent_db.claim_token or "").strip() != claim_token
|
||
):
|
||
logger.debug(
|
||
"generate_story_image: skip persist intent={} status={} claim={}",
|
||
intent.id,
|
||
getattr(intent_db, "status", None),
|
||
getattr(intent_db, "claim_token", None),
|
||
)
|
||
merge_fanout_item(
|
||
memoir_correlation_id,
|
||
list_name="story_images",
|
||
id_field="story_id",
|
||
item_id=story_id,
|
||
task_id=celery_tid,
|
||
status="superseded_or_cancelled",
|
||
)
|
||
return {"status": "superseded_or_cancelled"}
|
||
|
||
asset = Asset(
|
||
id=asset_id,
|
||
asset_type="story_image",
|
||
storage_key=cos_key,
|
||
url=url,
|
||
provider=settings.provider,
|
||
style_profile=style_for_image,
|
||
prompt_final=prompt_final,
|
||
status="completed",
|
||
)
|
||
db.add(asset)
|
||
db.flush()
|
||
|
||
story_db = db.get(Story, story_id)
|
||
target_vid = intent_db.story_version_id or story_db.current_version_id
|
||
current_vid = story_db.current_version_id
|
||
|
||
intent_db.asset_id = asset_id
|
||
intent_db.status = "completed"
|
||
intent_db.claim_token = None
|
||
intent_db.claimed_at = None
|
||
intent_db.error = None
|
||
intent_db.updated_at = datetime.now(timezone.utc)
|
||
db.flush()
|
||
|
||
# 仅当 intent 仍指向当前版本时回填正文,避免慢任务/重试把图插到新版本上
|
||
if not target_vid or target_vid != current_vid:
|
||
db.commit()
|
||
logger.debug(
|
||
"generate_story_image: stale intent skip backfill story={} "
|
||
"intent_ver={} current={} url={} asset={}",
|
||
story_id,
|
||
target_vid,
|
||
current_vid,
|
||
url,
|
||
asset_id,
|
||
)
|
||
merge_fanout_item(
|
||
memoir_correlation_id,
|
||
list_name="story_images",
|
||
id_field="story_id",
|
||
item_id=story_id,
|
||
task_id=celery_tid,
|
||
status="success_stale",
|
||
)
|
||
return {"status": "success_stale", "asset_id": asset_id}
|
||
|
||
ver = db.get(StoryVersion, target_vid)
|
||
if not ver:
|
||
db.commit()
|
||
merge_fanout_item(
|
||
memoir_correlation_id,
|
||
list_name="story_images",
|
||
id_field="story_id",
|
||
item_id=story_id,
|
||
task_id=celery_tid,
|
||
status="success_no_snapshot",
|
||
)
|
||
return {"status": "success_no_snapshot", "asset_id": asset_id}
|
||
|
||
base_md = strip_asset_image_refs_from_markdown(ver.markdown_snapshot or "")
|
||
alt_text = (getattr(intent_db, "prompt_brief", None) or "").strip()
|
||
if not alt_text:
|
||
alt_text = (getattr(intent_db, "caption", None) or "").strip()
|
||
backfilled_md = backfill_image_into_markdown(
|
||
base_md,
|
||
asset_id=asset_id,
|
||
image_alt=alt_text or "主插图",
|
||
)
|
||
max_stmt = select(func.max(StoryVersion.version_no)).where(
|
||
StoryVersion.story_id == story_id
|
||
)
|
||
max_no = db.execute(max_stmt).scalar()
|
||
version_no = (max_no or 0) + 1
|
||
new_ver = StoryVersion(
|
||
id=str(uuid.uuid4()),
|
||
story_id=story_id,
|
||
version_no=version_no,
|
||
markdown_snapshot=backfilled_md,
|
||
change_summary="主插图回填",
|
||
actor_type="system",
|
||
source_type="image_backfill",
|
||
parent_version_id=story_db.current_version_id,
|
||
)
|
||
db.add(new_ver)
|
||
db.flush()
|
||
story_db.current_version_id = new_ver.id
|
||
story_db.canonical_markdown = backfilled_md
|
||
|
||
db.commit()
|
||
|
||
_enqueue_chapter_effects_after_image_backfill(story_id)
|
||
|
||
ms = (time.perf_counter() - t0) * 1000
|
||
logger.info(
|
||
"event=story_image_task_done story_id={} asset_id={} duration_ms={:.1f} "
|
||
"msg=故事主图生成完成",
|
||
story_id,
|
||
asset_id,
|
||
ms,
|
||
)
|
||
logger.debug(
|
||
"generate_story_image: story={} asset={} url={} cos_key={} prompt_final={}",
|
||
story_id,
|
||
asset_id,
|
||
url,
|
||
cos_key,
|
||
prompt_final,
|
||
)
|
||
merge_fanout_item(
|
||
memoir_correlation_id,
|
||
list_name="story_images",
|
||
id_field="story_id",
|
||
item_id=story_id,
|
||
task_id=celery_tid,
|
||
status="success",
|
||
)
|
||
return {"status": "success", "asset_id": asset_id}
|
||
except Exception as exc:
|
||
if intent is not None:
|
||
with get_sync_db() as db:
|
||
intent_db = db.get(StoryImageIntent, intent.id)
|
||
if (
|
||
intent_db
|
||
and (intent_db.status or "").strip() != "completed"
|
||
and (intent_db.claim_token or "").strip() == claim_token
|
||
):
|
||
intent_db.status = "failed"
|
||
intent_db.claim_token = None
|
||
intent_db.claimed_at = None
|
||
intent_db.error = str(exc)
|
||
intent_db.updated_at = datetime.now(timezone.utc)
|
||
db.commit()
|
||
merge_fanout_item(
|
||
memoir_correlation_id,
|
||
list_name="story_images",
|
||
id_field="story_id",
|
||
item_id=story_id,
|
||
task_id=celery_tid,
|
||
status="failure",
|
||
extra={"error": str(exc)},
|
||
)
|
||
ms = (time.perf_counter() - t0) * 1000
|
||
logger.warning(
|
||
"event=story_image_task_failed story_id={} duration_ms={:.1f} error={} "
|
||
"msg=故事主图生成失败",
|
||
story_id,
|
||
ms,
|
||
exc,
|
||
)
|
||
raise self.retry(exc=exc) from exc
|
||
finally:
|
||
release_redis_lock(lock_handle)
|