diff --git a/insta-lab/Dockerfile b/insta-lab/Dockerfile index 8f5fb7a..b11e4ae 100644 --- a/insta-lab/Dockerfile +++ b/insta-lab/Dockerfile @@ -3,22 +3,13 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /app -# Korean fonts + Chromium runtime deps (Debian 12 / bookworm) -# `playwright install --with-deps`를 쓰지 않는 이유: 그 명령은 Ubuntu 패키지명을 -# 사용해 Debian에서 ttf-ubuntu-font-family / ttf-unifont 등 없는 패키지를 시도 -# → apt 실패. 대신 Chromium이 실제 필요로 하는 라이브러리만 명시 설치. +# Korean fonts (insta-lab가 자체 텍스트 처리는 안 하지만 향후 thumbnail 생성 등 위해 유지) RUN apt-get update && apt-get install -y --no-install-recommends \ fonts-noto-cjk fonts-noto-cjk-extra \ - libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ - libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \ - libxfixes3 libxrandr2 libgbm1 libxshmfence1 libpango-1.0-0 \ - libcairo2 libasound2 libatspi2.0-0 \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . -# --timeout 600 --retries 5: NAS 느린 네트워크/CPU에서 pip 다운로드 timeout 방지 RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt -RUN playwright install chromium COPY . . diff --git a/insta-lab/app/card_renderer.py b/insta-lab/app/card_renderer.py index 52010ff..1ea92ba 100644 --- a/insta-lab/app/card_renderer.py +++ b/insta-lab/app/card_renderer.py @@ -1,165 +1,7 @@ -"""Jinja → HTML → Playwright headless screenshot.""" +"""DEPRECATED 2026-05-19 — NAS에서 카드 렌더 안 함. Windows insta-render 워커로 이전됨. -import asyncio -import hashlib -import json -import logging -import os -import tempfile -from pathlib import Path -from typing import List +기존 render_slate, init_browser, shutdown_browser는 모두 web-ai/services/insta-render/card_renderer.py로 이식. +NAS insta-lab은 Redis push (queue:insta-render)만 담당. -from jinja2 import Environment, FileSystemLoader, select_autoescape -from playwright.async_api import async_playwright - -from .config import CARDS_DIR, CARD_TEMPLATE_DIR -from . import db - -logger = logging.getLogger(__name__) - -# NAS Celeron 2C 환경에서 Chromium을 동시에 여러 인스턴스로 띄우면 CPU/메모리 폭주. -# 슬레이트 렌더는 디스크 I/O와 Chromium launch가 직렬화되어도 충분히 빠르므로 -# 단일 슬롯으로 직렬화한다. (CHECK_POINT FU-C) -_RENDER_SEMAPHORE: asyncio.Semaphore | None = None - - -def _render_semaphore() -> asyncio.Semaphore: - global _RENDER_SEMAPHORE - if _RENDER_SEMAPHORE is None: - _RENDER_SEMAPHORE = asyncio.Semaphore(1) - return _RENDER_SEMAPHORE - - -# Chromium 브라우저 풀 — 매 슬레이트마다 launch 하지 않고 1개를 살려둠. -# (CHECK_POINT 중기-6) 카드 10장 렌더 시간 ~30% 단축 기대. -_PLAYWRIGHT = None -_BROWSER = None - - -async def init_browser() -> None: - """앱 startup hook에서 1회 호출. 이미 살아있으면 no-op.""" - global _PLAYWRIGHT, _BROWSER - if _BROWSER is not None and _BROWSER.is_connected(): - return - _PLAYWRIGHT = await async_playwright().start() - _BROWSER = await _PLAYWRIGHT.chromium.launch() - logger.info("Chromium browser pool 초기화 완료") - - -async def shutdown_browser() -> None: - """앱 shutdown hook에서 1회 호출.""" - global _PLAYWRIGHT, _BROWSER - if _BROWSER is not None: - try: - await _BROWSER.close() - except Exception: - logger.exception("browser close 중 예외 (무시)") - _BROWSER = None - if _PLAYWRIGHT is not None: - try: - await _PLAYWRIGHT.stop() - except Exception: - logger.exception("playwright stop 중 예외 (무시)") - _PLAYWRIGHT = None - - -async def _get_browser(): - """현재 브라우저 핸들 반환. crashed/None이면 재초기화 후 반환.""" - global _BROWSER - if _BROWSER is None or not _BROWSER.is_connected(): - await init_browser() - return _BROWSER - - -def _resolve_template_dir() -> str: - """Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/.""" - if os.path.isdir(CARD_TEMPLATE_DIR): - return CARD_TEMPLATE_DIR - return os.path.join(os.path.dirname(__file__), "templates") - - -def _env() -> Environment: - return Environment( - loader=FileSystemLoader(_resolve_template_dir()), - autoescape=select_autoescape(["html", "j2"]), - ) - - -def _slate_dir(slate_id: int) -> str: - out = os.path.join(CARDS_DIR, str(slate_id)) - os.makedirs(out, exist_ok=True) - return out - - -def _build_pages(slate: dict) -> List[dict]: - cover = json.loads(slate["cover_copy"] or "{}") - bodies = json.loads(slate["body_copies"] or "[]") - cta = json.loads(slate["cta_copy"] or "{}") - accent = cover.get("accent_color") or "#0F62FE" - pages: List[dict] = [] - pages.append({ - "page_type": "cover", "page_no": 1, "total_pages": 10, - "headline": cover.get("headline", ""), "body": cover.get("body", ""), - "accent_color": accent, "cta": "", - }) - for i, b in enumerate(bodies[:8]): - pages.append({ - "page_type": "body", "page_no": i + 2, "total_pages": 10, - "headline": b.get("headline", ""), "body": b.get("body", ""), - "accent_color": accent, "cta": "", - }) - pages.append({ - "page_type": "cta", "page_no": 10, "total_pages": 10, - "headline": cta.get("headline", ""), "body": cta.get("body", ""), - "accent_color": accent, "cta": cta.get("cta", ""), - }) - return pages - - -async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]: - async with _render_semaphore(): - return await _render_slate_locked(slate_id, template) - - -async def _render_slate_locked(slate_id: int, template: str) -> List[str]: - slate = db.get_card_slate(slate_id) - if not slate: - raise ValueError(f"slate {slate_id} not found") - env = _env() - - # template 파일이 없으면 default로 폴백 (INSTA_DEFAULT_THEME가 import 안 된 theme이면 안전) - template_full = Path(_resolve_template_dir()) / template - if not template_full.exists(): - logger.warning("Template '%s' 없음 → 'default/card.html.j2'로 폴백", template) - template = "default/card.html.j2" - - tmpl = env.get_template(template) - pages = _build_pages(slate) - out_dir = _slate_dir(slate_id) - paths: List[str] = [] - - browser = await _get_browser() - ctx = await browser.new_context(viewport={"width": 1080, "height": 1350}) - try: - page = await ctx.new_page() - for spec in pages: - html_str = tmpl.render(**spec) - with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f: - f.write(html_str) - html_path = f.name - try: - await page.goto(f"file://{html_path}", wait_until="networkidle") - out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png") - await page.screenshot(path=out_path, full_page=False, omit_background=False) - with open(out_path, "rb") as fp: - file_hash = hashlib.md5(fp.read()).hexdigest() - db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash) - paths.append(out_path) - finally: - try: - os.unlink(html_path) - except OSError: - pass - finally: - await ctx.close() - return paths +이 파일은 임포트 호환성 위해서만 존재. 새 코드는 이 모듈을 import하지 말 것. +""" diff --git a/insta-lab/app/main.py b/insta-lab/app/main.py index 93b56ac..49fb1dd 100644 --- a/insta-lab/app/main.py +++ b/insta-lab/app/main.py @@ -18,7 +18,7 @@ from .config import ( ) import redis.asyncio as aioredis -from . import db, news_collector, keyword_extractor, card_writer, card_renderer, trend_collector +from . import db, news_collector, keyword_extractor, card_writer, trend_collector from .internal_router import router as internal_router logger = logging.getLogger(__name__) @@ -42,13 +42,11 @@ app.add_middleware( async def on_startup(): os.makedirs(INSTA_DATA_PATH, exist_ok=True) db.init_db() - # Chromium browser pool 초기화 (CHECK_POINT 중기-6) - await card_renderer.init_browser() @app.on_event("shutdown") async def on_shutdown(): - await card_renderer.shutdown_browser() + pass @app.get("/health") diff --git a/insta-lab/requirements.txt b/insta-lab/requirements.txt index 8b38775..18c93d3 100644 --- a/insta-lab/requirements.txt +++ b/insta-lab/requirements.txt @@ -4,7 +4,6 @@ requests==2.32.3 httpx>=0.27 anthropic==0.52.0 jinja2>=3.1.4 -playwright==1.48.0 Pillow>=10 pytest>=8.0 pytest-asyncio>=0.24 diff --git a/insta-lab/tests/test_card_renderer.py b/insta-lab/tests/test_card_renderer.py deleted file mode 100644 index 929a20d..0000000 --- a/insta-lab/tests/test_card_renderer.py +++ /dev/null @@ -1,59 +0,0 @@ -import os -import tempfile - -import pytest - -from app import db as db_module -from app import card_renderer - - -@pytest.fixture -def tmp_db_and_dirs(monkeypatch, tmp_path): - fd, path = tempfile.mkstemp(suffix=".db") - os.close(fd) - monkeypatch.setattr(db_module, "DB_PATH", path) - monkeypatch.setattr(card_renderer, "CARDS_DIR", str(tmp_path / "cards")) - db_module.init_db() - yield path - import gc - gc.collect() - for ext in ("", "-wal", "-shm"): - try: - os.remove(path + ext) - except OSError: - pass - - -def _seed_slate() -> int: - return db_module.add_card_slate({ - "keyword": "테스트", - "category": "economy", - "status": "draft", - "cover_copy": {"headline": "커버 헤드라인", "body": "서브카피", "accent_color": "#0F62FE"}, - "body_copies": [{"headline": f"본문 {i+1}", "body": f"내용 {i+1}"} for i in range(8)], - "cta_copy": {"headline": "마무리", "body": "감사합니다", "cta": "팔로우"}, - }) - - -@pytest.mark.asyncio -async def test_render_slate_produces_ten_pngs(tmp_db_and_dirs): - sid = _seed_slate() - paths = await card_renderer.render_slate(sid) - assert len(paths) == 10 - for p in paths: - assert os.path.exists(p) - assert os.path.getsize(p) > 1000 # > 1 KB sanity - db_module.update_slate_status(sid, "rendered") - assets = db_module.list_card_assets(sid) - assert {a["page_index"] for a in assets} == set(range(1, 11)) - - -@pytest.mark.asyncio -async def test_render_falls_back_to_default_when_theme_html_missing(tmp_db_and_dirs): - """존재하지 않는 theme HTML 지정 시 default/card.html.j2로 폴백, 정상 PNG 생성.""" - sid = _seed_slate() - paths = await card_renderer.render_slate(sid, template="ghost_theme/card.html.j2") - assert len(paths) == 10 - for p in paths: - assert os.path.exists(p) - assert os.path.getsize(p) > 1000 diff --git a/insta-lab/tests/test_main.py b/insta-lab/tests/test_main.py index 7ae31ce..b8c21e9 100644 --- a/insta-lab/tests/test_main.py +++ b/insta-lab/tests/test_main.py @@ -58,7 +58,11 @@ def test_keywords_listing(client): def test_create_slate_kicks_background_task(client, monkeypatch): - from app import main, card_writer, card_renderer + """Plan-B-Insta SP-4: 슬레이트 생성 후 Redis push → task status=processing (Windows worker 대기). + + card_renderer는 NAS에서 제거됨. write_slate → Redis rpush 경로만 검증. + """ + from app import main, card_writer def fake_write(keyword, category, articles=None): return db_module.add_card_slate({ @@ -68,24 +72,25 @@ def test_create_slate_kicks_background_task(client, monkeypatch): "cta_copy": {"headline": "C", "body": "B", "cta": "F"}, }) - async def fake_render(slate_id, template="default/card.html.j2"): - for i in range(1, 11): - db_module.add_card_asset(slate_id, i, f"/tmp/{slate_id}_{i}.png", "h") - return [f"/tmp/{slate_id}_{i}.png" for i in range(1, 11)] + async def fake_rpush(queue, payload): + pass # Redis 없이도 테스트 통과 monkeypatch.setattr(card_writer, "write_slate", fake_write) - monkeypatch.setattr(card_renderer, "render_slate", fake_render) + monkeypatch.setattr(main.redis_client, "rpush", fake_rpush) resp = client.post("/api/insta/slates", json={"keyword": "K", "category": "economy"}) assert resp.status_code == 200 task_id = resp.json()["task_id"] - # poll task + # 잠시 대기 후 폴링 — background task가 완료될 때까지 + import time for _ in range(20): st = client.get(f"/api/insta/tasks/{task_id}").json() - if st["status"] in ("succeeded", "failed"): + if st["status"] != "pending": break - assert st["status"] == "succeeded" + time.sleep(0.1) + # Redis push 후 task는 processing 상태 (Windows worker가 rendered로 전환) + assert st["status"] == "processing" + assert st["result_id"] is not None # slate_id가 result_id에 기록됨 slate_id = st["result_id"] detail = client.get(f"/api/insta/slates/{slate_id}").json() - assert detail["status"] == "rendered" - assert len(detail["assets"]) == 10 + assert detail["keyword"] == "K"