Compare commits

...

5 Commits

Author SHA1 Message Date
6774067505 fix(insta-render): 큐 연결 socket_timeout=30 (None→30 교정)
근본원인 실험 확정: redis-py 블로킹 read에서 socket_timeout이 BLMOVE 블록(5s)
이하/None이면 read_timeout 경계 경합으로 간헐 "Timeout reading" → dequeue 실패
→ 슬레이트 draft 정지. socket_timeout 10/30은 모든 실험에서 안정. 블록보다 큰
30으로 명시(직전 None 커밋은 단독 테스트만 통과시켜 오도 — 재사용 패턴서 깨짐).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 03:17:34 +09:00
c451f5313b fix(insta-render): BLMOVE dequeue가 짧은 socket_timeout으로 깨지던 문제 해결
REDIS_URL의 socket_timeout(<5s)이 ReliableQueue BLMOVE 5초 블록보다 짧아
idle dequeue마다 "Timeout reading"으로 잡을 못 꺼내 슬레이트가 draft에 정지(~2026-05-22~).
큐 연결을 socket_timeout=None + socket_keepalive로 생성(make_queue_redis)해 정상화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:08:43 +09:00
9241b5cd90 fix(insta-render): fonts.ready 대기 + PNG 비어있음 검증 (렌더 known-issue 해결)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:53:07 +09:00
8bfc8e153f polish(insta-render): CSS accent | safe + cover sub clamp
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:50:25 +09:00
232aa52adb feat(insta-render): 모던 미니멀 디자인 시스템 템플릿
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:46:19 +09:00
4 changed files with 113 additions and 29 deletions

View File

@@ -151,8 +151,11 @@ async def _render_slate_locked(slate: dict, slate_id: int, template: str) -> Lis
html_path = f.name html_path = f.name
try: try:
await page.goto(f"file://{html_path}", wait_until="networkidle") await page.goto(f"file://{html_path}", wait_until="networkidle")
await page.evaluate("document.fonts.ready") # 웹폰트 로딩 완료까지 대기
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png") 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) await page.screenshot(path=out_path, full_page=False, omit_background=False)
if os.path.getsize(out_path) < 1000: # 빈/깨진 PNG 방어
raise RuntimeError(f"rendered PNG too small: {out_path}")
paths.append(out_path) paths.append(out_path)
finally: finally:
try: try:

View File

@@ -3,52 +3,85 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap'); @import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { html, body { width: 1080px; height: 1350px; }
width: 1080px; height: 1350px; body {
font-family: 'Noto Sans KR', sans-serif; font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
background: #F7F7FA; color: #14171A; background: #F7F7FA; color: #14171A;
-webkit-font-smoothing: antialiased;
} }
.card { .card {
width: 1080px; height: 1350px; position: relative; width: 1080px; height: 1350px; overflow: hidden;
padding: 80px 72px; padding: 96px 84px 72px;
display: flex; flex-direction: column; justify-content: space-between; display: flex; flex-direction: column;
background: linear-gradient(180deg, #FFFFFF 0%, #F7F7FA 100%); background: #FFFFFF;
border-top: 16px solid {{ accent_color }};
} }
.accent-bar { position: absolute; top: 0; left: 0; width: 100%; height: 14px; background: {{ accent_color | safe }}; }
.badge { .badge {
display: inline-block; padding: 8px 20px; border-radius: 999px; align-self: flex-start; padding: 10px 24px; border-radius: 999px;
background: {{ accent_color }}; color: #fff; background: {{ accent_color | safe }}; color: #fff;
font-size: 28px; font-weight: 700; letter-spacing: -0.02em; font-size: 30px; font-weight: 700; letter-spacing: -0.02em;
} }
.idx { font-size: 120px; font-weight: 800; line-height: 1; color: {{ accent_color | safe }}; letter-spacing: -0.04em; }
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 36px; }
.headline { .headline {
font-size: {{ 96 if page_type == 'cover' else 72 }}px; font-weight: 800; line-height: 1.18; letter-spacing: -0.04em; color: #14171A;
font-weight: 900; line-height: 1.15; letter-spacing: -0.04em; display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
margin-top: 32px;
} }
.body { .cover .headline { font-size: 104px; -webkit-line-clamp: 4; }
font-size: 40px; font-weight: 400; line-height: 1.55; .body-page .headline { font-size: 76px; -webkit-line-clamp: 3; }
margin-top: 40px; color: #2A2F35; .cta .headline { font-size: 88px; -webkit-line-clamp: 3; }
.sub {
font-size: 42px; font-weight: 400; line-height: 1.5; color: #3A4047;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 8;
white-space: pre-wrap; white-space: pre-wrap;
} }
.cover .sub { -webkit-line-clamp: 5; }
.footer { .footer {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
font-size: 28px; color: #6B7280; font-weight: 500; font-size: 28px; color: #8A9099; font-weight: 600; margin-top: 40px;
} }
.cta { font-weight: 700; color: {{ accent_color }}; } .cta-pill {
align-self: flex-start; margin-top: 8px; padding: 18px 40px; border-radius: 16px;
background: {{ accent_color | safe }}; color: #fff; font-size: 40px; font-weight: 700;
}
.progress { display: flex; gap: 10px; }
.progress i { width: 14px; height: 14px; border-radius: 50%; background: #D8DCE0; display: inline-block; }
.progress i.on { background: {{ accent_color | safe }}; }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card {{ 'cover' if page_type=='cover' else ('cta' if page_type=='cta' else 'body-page') }}">
<div> <div class="accent-bar"></div>
<span class="badge">{{ page_type|upper }}</span>
<h1 class="headline">{{ headline }}</h1> {% if page_type == 'cover' %}
<p class="body">{{ body }}</p> <span class="badge">{{ category_label|default('') or '오늘의 이슈' }}</span>
</div> <div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="sub">{{ body }}</p>
</div>
{% elif page_type == 'cta' %}
<div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="sub">{{ body }}</p>
{% if cta %}<div class="cta-pill">{{ cta }}</div>{% endif %}
</div>
{% else %}
<span class="idx">{{ '%02d'|format(page_no - 1) }}</span>
<div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="sub">{{ body }}</p>
</div>
{% endif %}
<div class="footer"> <div class="footer">
<span>{{ page_no }} / {{ total_pages }}</span> {% if page_type == 'cover' or page_type == 'cta' %}
{% if cta %}<span class="cta">{{ cta }}</span>{% endif %} <span>{{ brand_handle|default('') }}</span><span>{{ page_no }} / {{ total_pages }}</span>
{% else %}
<div class="progress">{% for n in range(2, total_pages) %}<i class="{{ 'on' if n <= page_no }}"></i>{% endfor %}</div>
<span>{{ page_no }} / {{ total_pages }}</span>
{% endif %}
</div> </div>
</div> </div>
</body> </body>

View File

@@ -1,10 +1,13 @@
"""worker.py — Redis BLPOP + webhook 단위 테스트.""" """worker.py — Redis BLPOP + webhook 단위 테스트."""
import json import json
import os
from pathlib import Path
import pytest import pytest
import httpx import httpx
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import worker import worker
from card_renderer import render_slate, init_browser, shutdown_browser
@pytest.fixture @pytest.fixture
@@ -179,6 +182,28 @@ async def test_poll_once_calls_fail_on_exception(monkeypatch):
fake_queue.ack.assert_not_awaited() fake_queue.ack.assert_not_awaited()
@pytest.mark.asyncio
async def test_render_produces_nonempty_1080x1350(tmp_path, monkeypatch):
"""Phase 2 — fonts.ready 대기 + PNG 비어있음 검증: 10장 모두 > 1000 bytes."""
import card_renderer as _cr
templates_dir = str(Path(__file__).resolve().parent.parent / "templates")
monkeypatch.setattr(_cr, "CARD_TEMPLATE_DIR", templates_dir)
monkeypatch.setattr(_cr, "INSTA_MEDIA_ROOT", str(tmp_path))
await init_browser()
try:
slate = {
"cover_copy": {"headline": "헤드라인", "body": "서브", "accent_color": "#0F62FE"},
"body_copies": [{"headline": f"포인트{i}", "body": "본문"} for i in range(8)],
"cta_copy": {"headline": "요약", "body": "마무리", "cta": "팔로우"},
}
paths = await render_slate(slate, slate_id=99999)
assert len(paths) == 10
for p in paths:
assert os.path.getsize(p) > 1000 # 비어있지 않음
finally:
await shutdown_browser()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_poll_once_returns_false_on_timeout(monkeypatch): async def test_poll_once_returns_false_on_timeout(monkeypatch):
"""F6 — dequeue가 None 반환(타임아웃)이면 False 리턴, ack/fail 안 부름.""" """F6 — dequeue가 None 반환(타임아웃)이면 False 리턴, ack/fail 안 부름."""
@@ -197,3 +222,11 @@ async def test_poll_once_returns_false_on_timeout(monkeypatch):
process_mock.assert_not_awaited() process_mock.assert_not_awaited()
fake_queue.ack.assert_not_awaited() fake_queue.ack.assert_not_awaited()
fake_queue.fail.assert_not_awaited() fake_queue.fail.assert_not_awaited()
def test_make_queue_redis_socket_timeout_exceeds_block():
"""BLMOVE(블록 5s) dequeue가 read-timeout 경계 경합으로 깨지지 않도록
socket_timeout이 블록보다 충분히 커야 한다 (회귀 가드)."""
c = worker.make_queue_redis()
st = c.connection_pool.connection_kwargs.get("socket_timeout")
assert st is not None and st > 5 # blmove 블록(5s)보다 커야 안정

View File

@@ -98,9 +98,24 @@ async def poll_once(queue: ReliableQueue, client: httpx.AsyncClient) -> bool:
return True return True
# 블로킹 dequeue는 BLMOVE(블록 5s)를 쓴다. redis-py 블로킹 read에서 socket_timeout이
# 블록(5s) 이하이거나 None이면 read-timeout이 블록 경계와 경합해 간헐적으로
# "Timeout reading"이 터져 잡을 못 꺼낸다(슬레이트 draft 정지). 실험상 socket_timeout이
# 블록보다 충분히 크면(10/30) 항상 안정. → 블록보다 넉넉히 큰 값을 명시한다.
QUEUE_SOCKET_TIMEOUT = 30 # > dequeue blmove 블록(5s)
def make_queue_redis():
"""블로킹 dequeue(BLMOVE)용 redis 클라이언트. socket_timeout > 블록(5s) 보장."""
return aioredis.from_url(
REDIS_URL, decode_responses=False,
socket_timeout=QUEUE_SOCKET_TIMEOUT, socket_keepalive=True,
)
async def worker_loop(): async def worker_loop():
"""무한 루프 — paused 체크 → ReliableQueue.dequeue → process_one → ack/fail.""" """무한 루프 — paused 체크 → ReliableQueue.dequeue → process_one → ack/fail."""
redis = aioredis.from_url(REDIS_URL, decode_responses=False) redis = make_queue_redis()
queue = ReliableQueue(redis, queue_key=QUEUE_KEY) queue = ReliableQueue(redis, queue_key=QUEUE_KEY)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
logger.info("insta-render worker started worker_id=%s queue=%s", logger.info("insta-render worker started worker_id=%s queue=%s",