Compare commits
5 Commits
d2f7030446
...
6774067505
| Author | SHA1 | Date | |
|---|---|---|---|
| 6774067505 | |||
| c451f5313b | |||
| 9241b5cd90 | |||
| 8bfc8e153f | |||
| 232aa52adb |
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)보다 커야 안정
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user