diff --git a/packages/ai-server/src/config/_container.py b/packages/ai-server/src/config/_container.py index 07bd856e..efa6b703 100644 --- a/packages/ai-server/src/config/_container.py +++ b/packages/ai-server/src/config/_container.py @@ -75,6 +75,14 @@ class InfrastructureContainer(DeclarativeContainer): environment=environment, ) + # Generic LLM image-gen adapter (raw_posts + editorial_article 모두 사용, #429) + nano_banana_client: Singleton[NanoBananaClient] = Singleton( + NanoBananaClient, + api_key=Callable(lambda env: env.GEMINI_API_KEY, environment), + model=Callable(lambda env: env.NANO_BANANA_MODEL, environment), + timeout_seconds=Callable(lambda env: env.NANO_BANANA_TIMEOUT, environment), + ) + # #214 raw_posts_callback_client removed — ai-server writes DB directly. # ─── Postgres asyncpg pool — assets (raw_posts) vs operation (운영 DB) 분리 (#333, #369) ─── @@ -207,17 +215,10 @@ class RawPostsContainer(DeclarativeContainer): database_manager=infrastructure.assets_database_manager, ) - # #348 — processor stack (Nano Banana + Gemini Pro + Gemini Flash) - nano_banana_client: Singleton[NanoBananaClient] = Singleton( - NanoBananaClient, - api_key=Callable(lambda env: env.GEMINI_API_KEY, environment), - model=Callable(lambda env: env.NANO_BANANA_MODEL, environment), - timeout_seconds=Callable(lambda env: env.NANO_BANANA_TIMEOUT, environment), - ) - + # #348 — processor stack uses InfrastructureContainer.nano_banana_client (#429 이후 공유) hero_reframe_service: Singleton[HeroReframeService] = Singleton( HeroReframeService, - client=nano_banana_client, + client=infrastructure.nano_banana_client, ) items_parser: Singleton[ItemsParser] = Singleton( @@ -235,7 +236,7 @@ class RawPostsContainer(DeclarativeContainer): # #352 — composite crop + nano-banana 1:1 catalog photo per item. items_thumbnail_service: Singleton[ItemsThumbnailService] = Singleton( ItemsThumbnailService, - client=nano_banana_client, + client=infrastructure.nano_banana_client, r2=infrastructure.r2_client, ) diff --git a/packages/ai-server/src/editorial_article/graph.py b/packages/ai-server/src/editorial_article/graph.py index 2590234e..3ec2b8c3 100644 --- a/packages/ai-server/src/editorial_article/graph.py +++ b/packages/ai-server/src/editorial_article/graph.py @@ -1,7 +1,8 @@ """Stage 2 multi-source LangGraph 조립. -3-node MVP: fetch_sources → compose_layout → publish. -실패 시 즉시 종료, error_log 누적, status=failed 마킹은 ARQ task 가 담당. +4 nodes: fetch_sources → compose_layout → generate_hero → publish. +fetch / compose 는 fail-fast, generate_hero 는 graceful (실패해도 publish 진행 +하고 source post 이미지를 fallback hero 로 사용). """ from __future__ import annotations @@ -10,6 +11,7 @@ from .nodes.compose_layout import compose_layout_node from .nodes.fetch_sources import fetch_sources_node +from .nodes.generate_hero import generate_hero_node from .nodes.publish import publish_node from .state import EditorialArticleState @@ -23,6 +25,7 @@ def create_editorial_article_graph(): builder.add_node("fetch_sources", fetch_sources_node) builder.add_node("compose_layout", compose_layout_node) + builder.add_node("generate_hero", generate_hero_node) builder.add_node("publish", publish_node) builder.add_edge(START, "fetch_sources") @@ -34,8 +37,10 @@ def create_editorial_article_graph(): builder.add_conditional_edges( "compose_layout", _route_after, - {"ok": "publish", "failed": END}, + # generate_hero 는 graceful — 실패해도 publish 로 진행 + {"ok": "generate_hero", "failed": END}, ) + builder.add_edge("generate_hero", "publish") builder.add_edge("publish", END) return builder.compile() diff --git a/packages/ai-server/src/editorial_article/nodes/generate_hero.py b/packages/ai-server/src/editorial_article/nodes/generate_hero.py new file mode 100644 index 00000000..72653005 --- /dev/null +++ b/packages/ai-server/src/editorial_article/nodes/generate_hero.py @@ -0,0 +1,101 @@ +"""generate_hero node — nano-banana 매거진 cover 생성 + R2 업로드. + +compose_layout 결과의 angle_title / subtitle / 첫 섹션 텍스트 → 매거진 cover +prompt → nano-banana → R2 → layout.hero_image_url 갱신. + +Graceful fallback: nano-banana / R2 실패 시 graph 는 계속 진행 (publish 가 +기존 hero_image_url — compose_layout 이 source post 에서 픽한 이미지 — 를 +그대로 저장). 실패는 error_log 에만 기록, status='failed' 로 보지 않는다. +""" + +from __future__ import annotations + +import asyncio +import logging + +from langchain_core.runnables import RunnableConfig + +from src.managers.llm.adapters.nano_banana import NanoBananaClient, NanoBananaError +from src.managers.storage.r2_client import R2Client + +from ..models import MagazineLayout + + +logger = logging.getLogger(__name__) + + +def _build_hero_prompt(layout: MagazineLayout) -> str: + intro_section = next((s for s in layout.sections if s.type == "intro"), None) + intro_body = (intro_section.body if intro_section and intro_section.body else "")[ + :400 + ] + + return f"""Create a high-fashion editorial magazine cover image, 16:9 cinematic composition. + +Title: {layout.title} +Subtitle: {layout.subtitle or ''} +Editorial mood: {intro_body} + +Requirements: +- Sophisticated, magazine-cover aesthetic (Hypebeast TAGGED-style editorial photography) +- Strong visual hierarchy — single dominant subject or composition +- High-end color grading, sharp focus, professional fashion photography lighting +- NO text overlay, NO logos, NO watermarks — image only +- 16:9 aspect ratio, optimized for Open Graph / hero banner +- Avoid generic stock-photo look; aim for editorial intent + +Generate the image only.""" + + +async def generate_hero_node(state: dict, config: RunnableConfig) -> dict: + layout: MagazineLayout | None = state.get("layout") + if layout is None: + # compose_layout 이 실패했으면 graph 가 publish 로 안 가니 여기 안 옴. + return {} + + cfg = (config or {}).get("configurable", {}) or {} + nano: NanoBananaClient | None = cfg.get("nano_banana_client") + r2: R2Client | None = cfg.get("r2_client") + + if nano is None or r2 is None or not r2.is_configured(): + logger.info( + "generate_hero: nano-banana / R2 not configured — skipping (fallback to source image)" + ) + return {} + + article_id = state["article_id"] + + try: + prompt = _build_hero_prompt(layout) + image_bytes = await nano.generate(prompt=prompt, aspect_ratio="16:9") + except NanoBananaError as exc: + logger.warning("generate_hero: nano-banana failed (%s) — fallback", exc) + return {"error_log": [f"generate_hero: nano-banana: {exc}"]} + except Exception as exc: + logger.exception("generate_hero: unexpected error") + return {"error_log": [f"generate_hero: {type(exc).__name__}: {exc}"]} + + key = f"editorial-magazines/{article_id}/hero.png" + try: + result = await asyncio.to_thread(r2.put, key, image_bytes, "image/png") + except Exception as exc: + logger.warning("generate_hero: R2 upload failed (%s) — fallback", exc) + return {"error_log": [f"generate_hero: R2 upload: {exc}"]} + + if not result.url: + logger.warning("generate_hero: R2 returned no public URL — fallback") + return {} + + updated_layout = layout.model_copy(update={"hero_image_url": result.url}) + + # hero 섹션이 있으면 같이 갱신 + new_sections = [] + for sec in updated_layout.sections: + if sec.type == "hero": + new_sections.append(sec.model_copy(update={"image_url": result.url})) + else: + new_sections.append(sec) + updated_layout = updated_layout.model_copy(update={"sections": new_sections}) + + logger.info("generate_hero: ok article=%s url=%s", article_id, result.url) + return {"layout": updated_layout} diff --git a/packages/ai-server/src/managers/llm/adapters/nano_banana.py b/packages/ai-server/src/managers/llm/adapters/nano_banana.py index 2be2e48e..796173da 100644 --- a/packages/ai-server/src/managers/llm/adapters/nano_banana.py +++ b/packages/ai-server/src/managers/llm/adapters/nano_banana.py @@ -84,6 +84,40 @@ async def reframe( "nano-banana returned no image (likely safety/policy block)" ) + async def generate( + self, + *, + prompt: str, + aspect_ratio: str = "16:9", + image_size: str = "2K", + ) -> bytes: + """Text-to-image generation (no input image). Used for editorial hero + covers (#429). Same model, same error semantics as `reframe`. + """ + try: + resp = await self._client.aio.models.generate_content( + model=self._model, + contents=[prompt], + config=genai_types.GenerateContentConfig( + response_modalities=["IMAGE"], + image_config=genai_types.ImageConfig( + aspect_ratio=aspect_ratio, image_size=image_size + ), + ), + ) + except Exception as exc: + raise NanoBananaError(f"nano-banana request failed: {exc}") from exc + + for candidate in resp.candidates or []: + content = candidate.content + for part in (content.parts if content else []) or []: + inline = getattr(part, "inline_data", None) + if inline and inline.data: + return inline.data + raise NanoBananaError( + "nano-banana returned no image (likely safety/policy block)" + ) + @property def model(self) -> str: return self._model diff --git a/packages/ai-server/src/managers/queue/worker.py b/packages/ai-server/src/managers/queue/worker.py index 655e13eb..442c26da 100644 --- a/packages/ai-server/src/managers/queue/worker.py +++ b/packages/ai-server/src/managers/queue/worker.py @@ -122,6 +122,9 @@ async def create_worker( ) # #397 — pipeline 실패 알림 fire-and-forget (env 비면 no-op). ctx["telegram_notifier"] = infrastructure_container.telegram_notifier() + # #429 — editorial_article 의 generate_hero 노드용 (없으면 graceful fallback) + ctx["nano_banana_client"] = infrastructure_container.nano_banana_client() + ctx["r2_client"] = infrastructure_container.r2_client() # Create worker with settings worker = Worker( diff --git a/packages/ai-server/src/services/editorial_article/editorial_article_service.py b/packages/ai-server/src/services/editorial_article/editorial_article_service.py index 9d0145a5..d1cb78c0 100644 --- a/packages/ai-server/src/services/editorial_article/editorial_article_service.py +++ b/packages/ai-server/src/services/editorial_article/editorial_article_service.py @@ -27,6 +27,9 @@ async def editorial_article_job( recommendation_id: str, ) -> Dict[str, Any]: operation_db: DatabaseManager | None = ctx.get("operation_database_manager") + # optional — 없으면 generate_hero 가 graceful fallback (source post 이미지 사용) + nano_banana = ctx.get("nano_banana_client") + r2 = ctx.get("r2_client") if operation_db is None: logger.error("editorial_article_job: operation_database_manager missing") @@ -46,6 +49,8 @@ async def editorial_article_job( config = { "configurable": { "operation_database_manager": operation_db, + "nano_banana_client": nano_banana, + "r2_client": r2, } } diff --git a/packages/web/lib/components/admin/editorial/magazine/MagazineRenderer.tsx b/packages/web/lib/components/admin/editorial/magazine/MagazineRenderer.tsx index de822c0f..f83f876f 100644 --- a/packages/web/lib/components/admin/editorial/magazine/MagazineRenderer.tsx +++ b/packages/web/lib/components/admin/editorial/magazine/MagazineRenderer.tsx @@ -26,22 +26,75 @@ interface MagazineRendererProps { layout: MagazineLayout; } +/** + * Collage hero — curation_card 섹션의 image_url 들을 그리드로 묶어 단일 hero + * 자리에 배치. 단일 AI hero (nano-banana 생성) 보다: + * - source 이미지 fidelity 보존 → 매거진 내부와 시각적 일관성 + * - 추가 LLM 비용 / 시간 0 + * - editorial 톤이 자연스럽게 multi-subject 구성 + * + * Fallback: curation_card 이미지가 < 1 개면 단일 hero_image_url 사용. + */ +function pickCollageImages(layout: MagazineLayout): string[] { + const fromCards = layout.sections + .filter((s) => s.type === "curation_card" && Boolean(s.image_url)) + .map((s) => s.image_url as string); + if (fromCards.length > 0) return fromCards.slice(0, 4); + return layout.hero_image_url ? [layout.hero_image_url] : []; +} + +function CollageHero({ + images, + title, +}: { + images: string[]; + title: string; +}) { + if (images.length === 0) return null; + + // 이미지 개수별 grid 패턴 — 1: 단일, 2: 2-up, 3: 1+2, 4: 2x2 + const layout = + images.length === 1 + ? "grid-cols-1" + : images.length === 2 + ? "grid-cols-2" + : images.length === 3 + ? "grid-cols-3" + : "grid-cols-2 grid-rows-2"; + + return ( +
+ {images.map((src, i) => ( +
+ {`${title} +
+ ))} +
+ ); +} + export function MagazineRenderer({ layout }: MagazineRendererProps) { + const collage = pickCollageImages(layout); return (
- {layout.hero_image_url && ( -
- {layout.title} -
- )} +

{layout.title}

@@ -67,8 +120,9 @@ function SectionView({ index: number; }) { switch (section.type) { + // hero 는 header 의 collage 가 담당 — 본문에서 또 렌더하면 중복 case "hero": - return ; + return null; case "intro": return ; case "curation_card": @@ -82,35 +136,6 @@ function SectionView({ } } -function HeroSection({ section }: { section: MagazineSection }) { - return ( -
- {section.image_url && ( -
- {section.title -
- )} - {section.title && ( -

- {section.title} -

- )} - {section.body && ( -

- {section.body} -

- )} -
- ); -} - function ProseSection({ section, variant,