1374 lines
48 KiB
Markdown
1374 lines
48 KiB
Markdown
# Video Studio — Plan 1: 이미지 생성 백엔드 Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** image-lab(NAS) + image-render(Windows)를 신설해 `POST /api/image/generate` (provider 선택: gpt_image/nano_banana/flux) → Redis 큐 → Windows 워커 이미지 생성 → NAS webhook → 폴링으로 결과 image_url 확보. curl로 검증 가능한 작동 백엔드.
|
|
|
|
**Architecture:** video-lab/video-render 복제 패턴. NAS image-lab은 작업 큐 관리(SQLite image_tasks + Redis push + internal webhook), Windows image-render는 Redis BLPOP → provider dispatch → NAS SMB 저장 → webhook. 접근 B(클라이언트 오케스트레이션)의 백엔드 절반.
|
|
|
|
**Tech Stack:** FastAPI, Redis(aioredis/redis), SQLite, httpx/requests, OpenAI Images API, Gemini 2.5 Flash Image, ComfyUI(FLUX 로컬). 참조 spec: `docs/superpowers/specs/2026-05-23-video-studio-node-canvas-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**web-backend (NAS):**
|
|
- `image-lab/app/db.py` — image_tasks 테이블 + CRUD (video-lab 복제)
|
|
- `image-lab/app/auth.py` — verify_internal_key (video-lab 복제, 동일)
|
|
- `image-lab/app/internal_router.py` — `/api/internal/image/update` (video-lab 복제)
|
|
- `image-lab/app/main.py` — generate/tasks/providers 엔드포인트 (video-lab 복제 + provider 메타 교체)
|
|
- `image-lab/app/__init__.py`, `Dockerfile`, `requirements.txt`, `tests/`
|
|
- `docker-compose.yml` (수정), nginx 설정 (수정)
|
|
|
|
**web-ai (Windows):**
|
|
- `services/image-render/nas_client.py` — webhook 어댑터 (video-render 복제, 엔드포인트 교체)
|
|
- `services/image-render/providers/gpt_image.py` — OpenAI Images (신규)
|
|
- `services/image-render/providers/nano_banana.py` — Gemini Flash Image (신규)
|
|
- `services/image-render/providers/flux.py` — ComfyUI 로컬 (신규)
|
|
- `services/image-render/worker.py` — Redis BLPOP dispatch (video-render 복제)
|
|
- `services/image-render/main.py`, `Dockerfile`, `requirements.txt`, `tests/`
|
|
- `services/docker-compose.yml` (수정)
|
|
|
|
---
|
|
|
|
## Phase A — image-lab (NAS)
|
|
|
|
### Task 1: image-lab DB — image_tasks 테이블
|
|
|
|
**Files:**
|
|
- Create: `image-lab/app/__init__.py` (빈 파일)
|
|
- Create: `image-lab/app/db.py`
|
|
- Test: `image-lab/tests/test_db.py`
|
|
|
|
복제 원본: `video-lab/app/db.py`. 치환 규칙: `video_tasks`→`image_tasks`, `video_url`→`image_url`, `VIDEO_DATA_DIR`→`IMAGE_DATA_DIR`, `video.db`→`image.db`.
|
|
|
|
- [ ] **Step 1: 디렉토리 + 빈 __init__.py 생성**
|
|
|
|
```bash
|
|
mkdir -p image-lab/app image-lab/tests
|
|
touch image-lab/app/__init__.py image-lab/tests/__init__.py
|
|
```
|
|
|
|
- [ ] **Step 2: 실패 테스트 작성**
|
|
|
|
`image-lab/tests/test_db.py`:
|
|
```python
|
|
import os, tempfile, importlib
|
|
|
|
def _fresh_db(monkeypatch, tmp):
|
|
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
|
|
import app.db as db
|
|
importlib.reload(db)
|
|
db.init_db()
|
|
return db
|
|
|
|
def test_create_and_get_task(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db = _fresh_db(monkeypatch, tmp)
|
|
row = db.create_task("t1", "gpt_image", {"prompt": "a cat"})
|
|
assert row["id"] == "t1"
|
|
assert row["provider"] == "gpt_image"
|
|
assert row["status"] == "queued"
|
|
got = db.get_task("t1")
|
|
assert got["id"] == "t1"
|
|
assert db.get_task("nope") is None
|
|
|
|
def test_update_task_sets_image_url(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db = _fresh_db(monkeypatch, tmp)
|
|
db.create_task("t2", "nano_banana", {"prompt": "x"})
|
|
db.update_task("t2", "succeeded", 100, message="done", image_url="/media/image/t2.png")
|
|
got = db.get_task("t2")
|
|
assert got["status"] == "succeeded"
|
|
assert got["image_url"] == "/media/image/t2.png"
|
|
assert got["progress"] == 100
|
|
```
|
|
|
|
- [ ] **Step 3: 테스트 실패 확인**
|
|
|
|
Run: `cd image-lab && python -m pytest tests/test_db.py -v`
|
|
Expected: FAIL (`ModuleNotFoundError: app.db` 또는 import 실패)
|
|
|
|
- [ ] **Step 4: db.py 작성**
|
|
|
|
`image-lab/app/db.py` (video-lab/app/db.py 복제 후 치환):
|
|
```python
|
|
"""SQLite persistence for image_tasks. Single table — task 단위 추적만."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from typing import Any, Dict, Optional
|
|
|
|
DB_PATH = os.path.join(os.getenv("IMAGE_DATA_DIR", "/app/data"), "image.db")
|
|
|
|
|
|
@contextmanager
|
|
def _conn():
|
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=5000")
|
|
try:
|
|
yield conn
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def init_db() -> None:
|
|
with _conn() as conn:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS image_tasks (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
params TEXT NOT NULL,
|
|
status TEXT DEFAULT 'queued',
|
|
progress INTEGER DEFAULT 0,
|
|
message TEXT DEFAULT '',
|
|
image_url TEXT,
|
|
error TEXT,
|
|
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
)
|
|
"""
|
|
)
|
|
|
|
|
|
def _row_to_dict(row) -> Dict[str, Any]:
|
|
return {
|
|
"id": row["id"], "provider": row["provider"], "params": row["params"],
|
|
"status": row["status"], "progress": row["progress"], "message": row["message"],
|
|
"image_url": row["image_url"], "error": row["error"],
|
|
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
|
}
|
|
|
|
|
|
def create_task(task_id: str, provider: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
with _conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO image_tasks (id, provider, params) VALUES (?, ?, ?)",
|
|
(task_id, provider, json.dumps(params)),
|
|
)
|
|
row = conn.execute("SELECT * FROM image_tasks WHERE id = ?", (task_id,)).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def update_task(task_id: str, status: str, progress: int, message: str = "",
|
|
image_url: Optional[str] = None, error: Optional[str] = None) -> None:
|
|
with _conn() as conn:
|
|
conn.execute(
|
|
"""
|
|
UPDATE image_tasks
|
|
SET status = ?, progress = ?, message = ?, image_url = ?, error = ?,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
WHERE id = ?
|
|
""",
|
|
(status, progress, message, image_url, error, task_id),
|
|
)
|
|
|
|
|
|
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
|
with _conn() as conn:
|
|
row = conn.execute("SELECT * FROM image_tasks WHERE id = ?", (task_id,)).fetchone()
|
|
return _row_to_dict(row) if row else None
|
|
```
|
|
|
|
- [ ] **Step 5: 테스트 통과 확인**
|
|
|
|
Run: `cd image-lab && python -m pytest tests/test_db.py -v`
|
|
Expected: PASS (2 passed)
|
|
|
|
- [ ] **Step 6: 커밋**
|
|
|
|
```bash
|
|
git add image-lab/app/__init__.py image-lab/app/db.py image-lab/tests/__init__.py image-lab/tests/test_db.py
|
|
git commit -m "feat(image-lab): image_tasks 테이블 + CRUD (video-lab 복제)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: image-lab auth — verify_internal_key
|
|
|
|
**Files:**
|
|
- Create: `image-lab/app/auth.py`
|
|
- Test: `image-lab/tests/test_auth.py`
|
|
|
|
복제 원본: `video-lab/app/auth.py` (치환 없음, 그대로).
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성**
|
|
|
|
`image-lab/tests/test_auth.py`:
|
|
```python
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from app.auth import verify_internal_key
|
|
|
|
def test_no_server_key_rejects(monkeypatch):
|
|
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
|
|
with pytest.raises(HTTPException) as e:
|
|
verify_internal_key("anything")
|
|
assert e.value.status_code == 401
|
|
|
|
def test_wrong_key_rejects(monkeypatch):
|
|
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
|
|
with pytest.raises(HTTPException) as e:
|
|
verify_internal_key("wrong")
|
|
assert e.value.status_code == 401
|
|
|
|
def test_correct_key_passes(monkeypatch):
|
|
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
|
|
assert verify_internal_key("secret") is None
|
|
```
|
|
|
|
- [ ] **Step 2: 실패 확인**
|
|
|
|
Run: `cd image-lab && python -m pytest tests/test_auth.py -v`
|
|
Expected: FAIL (`ModuleNotFoundError: app.auth`)
|
|
|
|
- [ ] **Step 3: auth.py 작성 (video-lab 복제)**
|
|
|
|
`image-lab/app/auth.py`:
|
|
```python
|
|
"""Windows image-render worker → NAS image-lab internal webhook 인증."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from fastapi import Header, HTTPException
|
|
|
|
|
|
def verify_internal_key(x_internal_key: str = Header(...)):
|
|
expected = os.getenv("INTERNAL_API_KEY")
|
|
if not expected:
|
|
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
|
if x_internal_key != expected:
|
|
raise HTTPException(401, "Invalid X-Internal-Key")
|
|
```
|
|
|
|
- [ ] **Step 4: 통과 확인**
|
|
|
|
Run: `cd image-lab && python -m pytest tests/test_auth.py -v`
|
|
Expected: PASS (3 passed)
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
git add image-lab/app/auth.py image-lab/tests/test_auth.py
|
|
git commit -m "feat(image-lab): verify_internal_key (video-lab 복제)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: image-lab internal_router — /api/internal/image/update
|
|
|
|
**Files:**
|
|
- Create: `image-lab/app/internal_router.py`
|
|
- Test: `image-lab/tests/test_internal_router.py`
|
|
|
|
복제 원본: `video-lab/app/internal_router.py`. 치환: `/api/internal/video/update`→`/api/internal/image/update`, `video_url`→`image_url`, `video_update`→`image_update`.
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성**
|
|
|
|
`image-lab/tests/test_internal_router.py`:
|
|
```python
|
|
import os, tempfile, importlib
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
def _client(monkeypatch, tmp):
|
|
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
|
|
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
|
|
import app.db as db; importlib.reload(db); db.init_db()
|
|
import app.internal_router as ir; importlib.reload(ir)
|
|
app = FastAPI(); app.include_router(ir.router)
|
|
return TestClient(app), db
|
|
|
|
def test_update_requires_key(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
client, db = _client(monkeypatch, tmp)
|
|
db.create_task("t1", "gpt_image", {"prompt": "x"})
|
|
r = client.post("/api/internal/image/update",
|
|
json={"task_id": "t1", "status": "succeeded", "progress": 100})
|
|
assert r.status_code == 422 or r.status_code == 401 # header 누락
|
|
|
|
def test_update_succeeds_with_key(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
client, db = _client(monkeypatch, tmp)
|
|
db.create_task("t1", "gpt_image", {"prompt": "x"})
|
|
r = client.post("/api/internal/image/update",
|
|
headers={"X-Internal-Key": "secret"},
|
|
json={"task_id": "t1", "status": "succeeded", "progress": 100,
|
|
"image_url": "/media/image/t1.png"})
|
|
assert r.status_code == 200
|
|
assert db.get_task("t1")["image_url"] == "/media/image/t1.png"
|
|
|
|
def test_update_unknown_task_404(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
client, db = _client(monkeypatch, tmp)
|
|
r = client.post("/api/internal/image/update",
|
|
headers={"X-Internal-Key": "secret"},
|
|
json={"task_id": "nope", "status": "failed", "progress": 0})
|
|
assert r.status_code == 404
|
|
```
|
|
|
|
- [ ] **Step 2: 실패 확인**
|
|
|
|
Run: `cd image-lab && python -m pytest tests/test_internal_router.py -v`
|
|
Expected: FAIL (import 실패)
|
|
|
|
- [ ] **Step 3: internal_router.py 작성**
|
|
|
|
`image-lab/app/internal_router.py`:
|
|
```python
|
|
"""Windows image-render → NAS image-lab internal webhook."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from . import db
|
|
from .auth import verify_internal_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
class UpdatePayload(BaseModel):
|
|
task_id: str
|
|
status: str = Field(..., description="processing|succeeded|failed")
|
|
progress: int = Field(..., ge=0, le=100)
|
|
message: str = ""
|
|
image_url: Optional[str] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
@router.post("/api/internal/image/update", dependencies=[Depends(verify_internal_key)])
|
|
def image_update(payload: UpdatePayload):
|
|
task = db.get_task(payload.task_id)
|
|
if task is None:
|
|
raise HTTPException(404, f"task not found: {payload.task_id}")
|
|
db.update_task(payload.task_id, payload.status, payload.progress,
|
|
message=payload.message, image_url=payload.image_url, error=payload.error)
|
|
logger.info("internal/image/update task=%s status=%s progress=%d",
|
|
payload.task_id, payload.status, payload.progress)
|
|
return {"ok": True}
|
|
```
|
|
|
|
- [ ] **Step 4: 통과 확인**
|
|
|
|
Run: `cd image-lab && python -m pytest tests/test_internal_router.py -v`
|
|
Expected: PASS (3 passed)
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
git add image-lab/app/internal_router.py image-lab/tests/test_internal_router.py
|
|
git commit -m "feat(image-lab): /api/internal/image/update webhook (video-lab 복제)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: image-lab main — generate/tasks/providers 엔드포인트
|
|
|
|
**Files:**
|
|
- Create: `image-lab/app/main.py`
|
|
- Test: `image-lab/tests/test_main.py`
|
|
|
|
복제 원본: `video-lab/app/main.py`. 치환: `queue:video-render`→`queue:image-render`, `/api/video/*`→`/api/image/*`, provider set·메타 교체, `kind:"video"`→`kind:"image"`, job_type `{provider}_generation` 유지.
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성 (Redis push는 monkeypatch)**
|
|
|
|
`image-lab/tests/test_main.py`:
|
|
```python
|
|
import os, tempfile, importlib
|
|
from fastapi.testclient import TestClient
|
|
|
|
def _client(monkeypatch, tmp):
|
|
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
|
|
import app.db as db; importlib.reload(db)
|
|
import app.main as main; importlib.reload(main)
|
|
pushed = []
|
|
async def fake_push(task_id, job_type, params):
|
|
pushed.append((task_id, job_type, params))
|
|
monkeypatch.setattr(main, "_push_render_job", fake_push)
|
|
return TestClient(main.app), db, pushed
|
|
|
|
def test_providers_lists_three(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
client, _, _ = _client(monkeypatch, tmp)
|
|
r = client.get("/api/image/providers")
|
|
ids = {p["id"] for p in r.json()["providers"]}
|
|
assert ids == {"gpt_image", "nano_banana", "flux"}
|
|
|
|
def test_generate_rejects_unknown_provider(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
client, _, _ = _client(monkeypatch, tmp)
|
|
r = client.post("/api/image/generate", json={"provider": "midjourney", "prompt": "x"})
|
|
assert r.status_code == 400
|
|
|
|
def test_generate_creates_task_and_pushes(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
client, db, pushed = _client(monkeypatch, tmp)
|
|
r = client.post("/api/image/generate", json={"provider": "gpt_image", "prompt": "a cat"})
|
|
assert r.status_code == 200
|
|
task_id = r.json()["task_id"]
|
|
assert db.get_task(task_id)["status"] == "queued"
|
|
assert pushed[0][1] == "gpt_image_generation"
|
|
```
|
|
|
|
- [ ] **Step 2: 실패 확인**
|
|
|
|
Run: `cd image-lab && python -m pytest tests/test_main.py -v`
|
|
Expected: FAIL (import 실패)
|
|
|
|
- [ ] **Step 3: main.py 작성**
|
|
|
|
`image-lab/app/main.py`:
|
|
```python
|
|
"""FastAPI entrypoint for image-lab.
|
|
|
|
POST /api/image/generate — provider + prompt → Redis push → task_id
|
|
GET /api/image/tasks/{id} — DB 조회
|
|
GET /api/image/providers — 3 provider 메타
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json, logging, os, uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, Optional
|
|
|
|
import redis.asyncio as aioredis
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel, Field
|
|
|
|
from . import db
|
|
from .internal_router import router as internal_router
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")
|
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
|
|
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
|
|
|
|
SUPPORTED_PROVIDERS = {"gpt_image", "nano_banana", "flux"}
|
|
|
|
app = FastAPI()
|
|
app.include_router(internal_router)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[o.strip() for o in CORS_ALLOW_ORIGINS.split(",")],
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
|
allow_headers=["Content-Type"],
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def on_startup():
|
|
db.init_db()
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"ok": True, "service": "image-lab"}
|
|
|
|
|
|
@app.get("/api/image/providers")
|
|
def list_providers():
|
|
return {"providers": [
|
|
{"id": "gpt_image", "name": "GPT Image 2.0", "models": ["gpt-image-1"],
|
|
"sizes": ["1024x1024", "1024x1536", "1536x1024"]},
|
|
{"id": "nano_banana", "name": "Nano Banana (Gemini)", "models": ["gemini-2.5-flash-image"],
|
|
"sizes": ["1024x1024"]},
|
|
{"id": "flux", "name": "FLUX (local)", "models": ["flux-schnell", "flux-dev"],
|
|
"sizes": ["1024x1024", "832x1216", "1216x832"]},
|
|
]}
|
|
|
|
|
|
class GenerateRequest(BaseModel):
|
|
provider: str = Field(..., description="gpt_image|nano_banana|flux")
|
|
model: Optional[str] = None
|
|
prompt: str
|
|
size: Optional[str] = None
|
|
negative_prompt: Optional[str] = None
|
|
extra: Optional[Dict[str, Any]] = None
|
|
|
|
class Config:
|
|
extra = "allow"
|
|
|
|
|
|
async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
|
|
kst = timezone(timedelta(hours=9))
|
|
payload = {
|
|
"task_id": task_id, "kind": "image", "job_type": job_type,
|
|
"params": params, "submitted_at": datetime.now(kst).isoformat(),
|
|
}
|
|
await redis_client.rpush("queue:image-render", json.dumps(payload))
|
|
|
|
|
|
@app.post("/api/image/generate")
|
|
async def generate_image(req: GenerateRequest):
|
|
if req.provider not in SUPPORTED_PROVIDERS:
|
|
raise HTTPException(400, f"지원하지 않는 provider: {req.provider} (supported: {sorted(SUPPORTED_PROVIDERS)})")
|
|
task_id = str(uuid.uuid4())
|
|
params = req.model_dump(exclude_none=True)
|
|
db.create_task(task_id, req.provider, params)
|
|
job_type = f"{req.provider}_generation"
|
|
await _push_render_job(task_id, job_type, params)
|
|
return {"task_id": task_id, "provider": req.provider}
|
|
|
|
|
|
@app.get("/api/image/tasks/{task_id}")
|
|
def get_task_status(task_id: str):
|
|
t = db.get_task(task_id)
|
|
if not t:
|
|
raise HTTPException(404, "task not found")
|
|
return t
|
|
```
|
|
|
|
- [ ] **Step 4: 통과 확인**
|
|
|
|
Run: `cd image-lab && python -m pytest tests/test_main.py -v`
|
|
Expected: PASS (3 passed)
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
git add image-lab/app/main.py image-lab/tests/test_main.py
|
|
git commit -m "feat(image-lab): generate/tasks/providers 엔드포인트 (video-lab 복제)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: image-lab 컨테이너 + docker-compose + nginx 차단
|
|
|
|
**Files:**
|
|
- Create: `image-lab/Dockerfile`, `image-lab/requirements.txt`, `image-lab/env.example`
|
|
- Modify: `docker-compose.yml` (image-lab 서비스 추가)
|
|
- Modify: nginx 설정 (`/api/internal/image/` 3-layer 차단 — music/video 패턴 복제)
|
|
|
|
- [ ] **Step 1: requirements.txt 작성**
|
|
|
|
`image-lab/requirements.txt`:
|
|
```
|
|
fastapi==0.115.0
|
|
uvicorn[standard]==0.30.6
|
|
pydantic==2.9.2
|
|
redis==5.0.8
|
|
httpx==0.27.2
|
|
```
|
|
|
|
- [ ] **Step 2: Dockerfile 작성 (video-lab Dockerfile 참조, uvicorn --workers 1)**
|
|
|
|
`image-lab/Dockerfile`:
|
|
```dockerfile
|
|
FROM python:3.12-slim
|
|
WORKDIR /app
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
COPY app ./app
|
|
EXPOSE 8000
|
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
|
```
|
|
|
|
- [ ] **Step 3: docker-compose.yml에 image-lab 추가 + frontend.depends_on 등재**
|
|
|
|
`docker-compose.yml` (video-lab 블록 다음에 추가, 포트 18802는 NAS 사용 포트 18000/18500/18600/18700/18800/18801/18850/18900/18950 중 빈 자리로 확인됨):
|
|
|
|
**+ `frontend.depends_on` 리스트에 `image-lab` 추가** (nginx upstream 보장):
|
|
```yaml
|
|
frontend:
|
|
depends_on:
|
|
- lotto
|
|
- travel-proxy
|
|
# ... 기존 항목들
|
|
- video-lab
|
|
- image-lab # 신규
|
|
```
|
|
```yaml
|
|
image-lab:
|
|
build: ./image-lab
|
|
container_name: image-lab
|
|
restart: unless-stopped
|
|
ports:
|
|
- "18802:8000"
|
|
environment:
|
|
- REDIS_URL=redis://redis:6379
|
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
|
|
- IMAGE_DATA_DIR=/app/data
|
|
- CORS_ALLOW_ORIGINS=http://localhost:3007,http://localhost:8080
|
|
volumes:
|
|
- ${RUNTIME_PATH}/image-data:/app/data
|
|
depends_on:
|
|
- redis
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
|
interval: 60s
|
|
timeout: 5s
|
|
retries: 3
|
|
```
|
|
|
|
- [ ] **Step 4: scripts/* 5위치 등재 (NAS rsync + build + health + data dir)**
|
|
|
|
신규 lab 추가 시 누락하면 NAS deploy 실패(memory `feedback_nas_deploy_paths.md` rule 3). video-lab과 동일하게 다음 5위치에 `image-lab`/`image` 추가:
|
|
|
|
1. **`scripts/deploy-nas.sh`** — `SERVICES="... video-lab nginx scripts"` → `... video-lab image-lab nginx scripts`
|
|
2. **`scripts/deploy.sh`** — `BUILD_TARGETS="... video-lab frontend"` → `... video-lab image-lab frontend`
|
|
3. **`scripts/deploy.sh`** — `CONTAINER_NAMES="... video-lab frontend"` → `... video-lab image-lab frontend`
|
|
4. **`scripts/deploy.sh`** — `HEALTH_ENDPOINTS="... video-lab redis"` → `... video-lab image-lab redis`
|
|
5. **`scripts/deploy.sh`** — `DATA_DIRS="music stock insta realestate agent-office personal video"` → `... personal video image`
|
|
|
|
(frontend.depends_on은 Step 3에서 처리됨 → 총 6위치 완료)
|
|
|
|
- [ ] **Step 5: nginx `/api/internal/image/` 3-layer 차단**
|
|
|
|
기존 video(`/api/internal/video/`) 차단 블록을 찾아 동일 패턴으로 image 블록 추가. 검색:
|
|
```bash
|
|
grep -rn "api/internal/video" <nginx conf 경로>
|
|
```
|
|
찾은 블록을 복제해 `video`→`image` 치환 (location deny + 내부 전용 표시). 정확한 파일은 grep 결과로 확정.
|
|
|
|
- [ ] **Step 6: 빌드 + 헬스 확인**
|
|
|
|
```bash
|
|
docker compose build image-lab
|
|
docker compose up -d image-lab redis
|
|
curl http://localhost:18802/health
|
|
```
|
|
Expected: `{"ok":true,"service":"image-lab"}`
|
|
|
|
- [ ] **Step 7: 커밋**
|
|
|
|
```bash
|
|
git add image-lab/Dockerfile image-lab/requirements.txt image-lab/env.example docker-compose.yml scripts/deploy.sh scripts/deploy-nas.sh
|
|
git commit -m "feat(image-lab): Dockerfile + compose entry + scripts 6위치 + nginx 차단"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase B — image-render (Windows, web-ai repo)
|
|
|
|
> 이하 작업은 **web-ai repo**에서 진행. `cd C:\Users\jaeoh\Desktop\workspace\web-ai`.
|
|
|
|
### Task 6: image-render nas_client + worker scaffold
|
|
|
|
**Files:**
|
|
- Create: `services/image-render/nas_client.py`, `services/image-render/providers/__init__.py`
|
|
- Test: `services/image-render/tests/test_worker.py` (Task 10에서 채움 — 여기선 nas_client만)
|
|
- Test: `services/image-render/tests/test_nas_client.py`
|
|
|
|
복제 원본: `services/video-render/nas_client.py`. 치환: `/api/internal/video/update`→`/api/internal/image/update`, `video_url`→`image_url`, default `NAS_BASE_URL` 18801→18802.
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성**
|
|
|
|
`services/image-render/tests/test_nas_client.py`:
|
|
```python
|
|
import nas_client
|
|
|
|
def test_webhook_includes_image_url(monkeypatch):
|
|
captured = {}
|
|
def fake_post(payload): captured.update(payload)
|
|
monkeypatch.setattr(nas_client, "_post", fake_post)
|
|
nas_client.webhook_update_task("t1", "succeeded", 100, "done", image_url="/media/image/t1.png")
|
|
assert captured["task_id"] == "t1"
|
|
assert captured["image_url"] == "/media/image/t1.png"
|
|
|
|
def test_webhook_omits_none_fields(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setattr(nas_client, "_post", lambda p: captured.update(p))
|
|
nas_client.webhook_update_task("t2", "processing", 10, "working")
|
|
assert "image_url" not in captured and "error" not in captured
|
|
```
|
|
|
|
- [ ] **Step 2: 실패 확인**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/test_nas_client.py -v`
|
|
Expected: FAIL (import 실패)
|
|
|
|
- [ ] **Step 3: nas_client.py 작성**
|
|
|
|
`services/image-render/nas_client.py`:
|
|
```python
|
|
"""NAS webhook 어댑터 — Windows worker → NAS image-lab HTTP 위임."""
|
|
from __future__ import annotations
|
|
|
|
import logging, os
|
|
from typing import Any, Dict, Optional
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_TIMEOUT = 10.0
|
|
|
|
|
|
def _post(payload: Dict[str, Any]) -> None:
|
|
nas_base_url = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18802")
|
|
internal_api_key = os.getenv("INTERNAL_API_KEY", "")
|
|
url = f"{nas_base_url}/api/internal/image/update"
|
|
try:
|
|
r = httpx.post(url, headers={"X-Internal-Key": internal_api_key}, json=payload, timeout=_TIMEOUT)
|
|
if r.status_code != 200:
|
|
logger.error("webhook %s returned %d: %s", payload.get("task_id"), r.status_code, r.text[:200])
|
|
except Exception:
|
|
logger.exception("webhook %s 호출 실패", payload.get("task_id"))
|
|
|
|
|
|
def webhook_update_task(task_id: str, status: str, progress: int, message: str = "",
|
|
image_url: Optional[str] = None, error: Optional[str] = None) -> None:
|
|
payload: Dict[str, Any] = {"task_id": task_id, "status": status, "progress": progress, "message": message}
|
|
if image_url is not None:
|
|
payload["image_url"] = image_url
|
|
if error is not None:
|
|
payload["error"] = error
|
|
_post(payload)
|
|
```
|
|
|
|
- [ ] **Step 4: 통과 확인 + 커밋**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/test_nas_client.py -v` → PASS
|
|
```bash
|
|
git add services/image-render/nas_client.py services/image-render/providers/__init__.py services/image-render/tests/test_nas_client.py
|
|
git commit -m "feat(image-render): nas_client webhook adapter (video-render 복제)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: provider gpt_image — OpenAI Images API
|
|
|
|
**Files:**
|
|
- Create: `services/image-render/providers/gpt_image.py`
|
|
- Create: `services/image-render/providers/_media.py` (b64 → NAS SMB 저장 헬퍼, 3 provider 공용)
|
|
- Test: `services/image-render/tests/test_gpt_image.py`
|
|
|
|
- [ ] **Step 1: 공용 미디어 저장 헬퍼 작성**
|
|
|
|
`services/image-render/providers/_media.py`:
|
|
```python
|
|
"""b64 이미지 → NAS SMB 경로 저장 → /media/image URL 반환."""
|
|
from __future__ import annotations
|
|
|
|
import base64, os, uuid
|
|
|
|
IMAGE_MEDIA_ROOT = os.getenv("IMAGE_MEDIA_ROOT", "/mnt/nas/webpage/data/image")
|
|
IMAGE_MEDIA_URL_PREFIX = os.getenv("IMAGE_MEDIA_URL_PREFIX", "/media/image")
|
|
|
|
|
|
def save_b64_png(task_id: str, b64_data: str) -> str:
|
|
os.makedirs(IMAGE_MEDIA_ROOT, exist_ok=True)
|
|
fname = f"{task_id}-{uuid.uuid4().hex[:8]}.png"
|
|
path = os.path.join(IMAGE_MEDIA_ROOT, fname)
|
|
with open(path, "wb") as f:
|
|
f.write(base64.b64decode(b64_data))
|
|
return f"{IMAGE_MEDIA_URL_PREFIX}/{fname}"
|
|
```
|
|
|
|
- [ ] **Step 2: 실패 테스트 작성 (requests + _media monkeypatch)**
|
|
|
|
`services/image-render/tests/test_gpt_image.py`:
|
|
```python
|
|
import providers.gpt_image as gi
|
|
|
|
def test_missing_key_reports_failed(monkeypatch):
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
calls = []
|
|
monkeypatch.setattr(gi, "webhook_update_task", lambda *a, **k: calls.append((a, k)))
|
|
gi.run_gpt_image_generation("t1", {"prompt": "a cat"})
|
|
# 마지막 호출이 failed
|
|
assert calls[-1][0][1] == "failed"
|
|
|
|
def test_success_saves_and_reports_url(monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
calls = []
|
|
monkeypatch.setattr(gi, "webhook_update_task", lambda *a, **k: calls.append((a, k)))
|
|
monkeypatch.setattr(gi, "save_b64_png", lambda tid, b64: "/media/image/t1.png")
|
|
|
|
class FakeResp:
|
|
status_code = 200
|
|
def json(self): return {"data": [{"b64_json": "ZmFrZQ=="}]}
|
|
def raise_for_status(self): pass
|
|
monkeypatch.setattr(gi.requests, "post", lambda *a, **k: FakeResp())
|
|
|
|
gi.run_gpt_image_generation("t1", {"prompt": "a cat"})
|
|
succeeded = [c for c in calls if c[0][1] == "succeeded"]
|
|
assert succeeded and succeeded[-1][1]["image_url"] == "/media/image/t1.png"
|
|
```
|
|
|
|
- [ ] **Step 3: 실패 확인**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/test_gpt_image.py -v`
|
|
Expected: FAIL (import 실패)
|
|
|
|
- [ ] **Step 4: gpt_image.py 작성**
|
|
|
|
`services/image-render/providers/gpt_image.py`:
|
|
```python
|
|
"""GPT Image 2.0 — OpenAI Images API.
|
|
|
|
POST https://api.openai.com/v1/images/generations
|
|
body {model:"gpt-image-1", prompt, size, n:1} → data[0].b64_json
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging, os
|
|
import requests
|
|
|
|
from nas_client import webhook_update_task
|
|
from providers._media import save_b64_png
|
|
|
|
logger = logging.getLogger(__name__)
|
|
OPENAI_URL = "https://api.openai.com/v1/images/generations"
|
|
DEFAULT_MODEL = "gpt-image-1"
|
|
|
|
|
|
def run_gpt_image_generation(task_id: str, params: dict) -> None:
|
|
try:
|
|
if not os.getenv("OPENAI_API_KEY"):
|
|
webhook_update_task(task_id, "failed", 0, "", error="OPENAI_API_KEY 미설정 (Windows .env)")
|
|
return
|
|
webhook_update_task(task_id, "processing", 10, "GPT Image 호출 중...")
|
|
body = {
|
|
"model": params.get("model") or DEFAULT_MODEL,
|
|
"prompt": params["prompt"],
|
|
"size": params.get("size") or "1024x1024",
|
|
"n": 1,
|
|
}
|
|
resp = requests.post(
|
|
OPENAI_URL,
|
|
headers={"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}", "Content-Type": "application/json"},
|
|
json=body, timeout=120,
|
|
)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"OpenAI {resp.status_code}: {resp.text[:200]}")
|
|
return
|
|
b64 = resp.json()["data"][0]["b64_json"]
|
|
url = save_b64_png(task_id, b64)
|
|
webhook_update_task(task_id, "succeeded", 100, "완료", image_url=url)
|
|
except Exception as e:
|
|
logger.exception("gpt_image task=%s 실패", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
```
|
|
|
|
- [ ] **Step 5: 통과 확인 + 커밋**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/test_gpt_image.py -v` → PASS
|
|
```bash
|
|
git add services/image-render/providers/_media.py services/image-render/providers/gpt_image.py services/image-render/tests/test_gpt_image.py
|
|
git commit -m "feat(image-render): gpt_image provider + media helper (SP image)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: provider nano_banana — Gemini 2.5 Flash Image
|
|
|
|
**Files:**
|
|
- Create: `services/image-render/providers/nano_banana.py`
|
|
- Test: `services/image-render/tests/test_nano_banana.py`
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성**
|
|
|
|
`services/image-render/tests/test_nano_banana.py`:
|
|
```python
|
|
import providers.nano_banana as nb
|
|
|
|
def test_missing_key_reports_failed(monkeypatch):
|
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
|
calls = []
|
|
monkeypatch.setattr(nb, "webhook_update_task", lambda *a, **k: calls.append((a, k)))
|
|
nb.run_nano_banana_generation("t1", {"prompt": "a cat"})
|
|
assert calls[-1][0][1] == "failed"
|
|
|
|
def test_success_extracts_inline_data(monkeypatch):
|
|
monkeypatch.setenv("GEMINI_API_KEY", "g-test")
|
|
calls = []
|
|
monkeypatch.setattr(nb, "webhook_update_task", lambda *a, **k: calls.append((a, k)))
|
|
monkeypatch.setattr(nb, "save_b64_png", lambda tid, b64: "/media/image/t1.png")
|
|
|
|
class FakeResp:
|
|
status_code = 200
|
|
def json(self):
|
|
return {"candidates": [{"content": {"parts": [
|
|
{"inlineData": {"mimeType": "image/png", "data": "ZmFrZQ=="}}
|
|
]}}]}
|
|
monkeypatch.setattr(nb.requests, "post", lambda *a, **k: FakeResp())
|
|
|
|
nb.run_nano_banana_generation("t1", {"prompt": "a cat"})
|
|
assert [c for c in calls if c[0][1] == "succeeded"]
|
|
```
|
|
|
|
- [ ] **Step 2: 실패 확인**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/test_nano_banana.py -v`
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: nano_banana.py 작성**
|
|
|
|
`services/image-render/providers/nano_banana.py`:
|
|
```python
|
|
"""Nano Banana — Gemini 2.5 Flash Image (generativelanguage API).
|
|
|
|
POST /v1beta/models/{MODEL}:generateContent
|
|
→ candidates[0].content.parts[*].inlineData.data (b64 png)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging, os
|
|
import requests
|
|
|
|
from nas_client import webhook_update_task
|
|
from providers._media import save_b64_png
|
|
|
|
logger = logging.getLogger(__name__)
|
|
GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
|
DEFAULT_MODEL = "gemini-2.5-flash-image"
|
|
|
|
|
|
def _extract_b64(data: dict):
|
|
for cand in data.get("candidates", []):
|
|
for part in cand.get("content", {}).get("parts", []):
|
|
inline = part.get("inlineData") or part.get("inline_data")
|
|
if inline and inline.get("data"):
|
|
return inline["data"]
|
|
return None
|
|
|
|
|
|
def run_nano_banana_generation(task_id: str, params: dict) -> None:
|
|
try:
|
|
if not os.getenv("GEMINI_API_KEY"):
|
|
webhook_update_task(task_id, "failed", 0, "", error="GEMINI_API_KEY 미설정 (Windows .env)")
|
|
return
|
|
webhook_update_task(task_id, "processing", 10, "Nano Banana (Gemini) 호출 중...")
|
|
model_id = params.get("model") or DEFAULT_MODEL
|
|
body = {"contents": [{"parts": [{"text": params["prompt"]}]}]}
|
|
resp = requests.post(
|
|
f"{GEMINI_BASE}/models/{model_id}:generateContent",
|
|
headers={"x-goog-api-key": os.getenv("GEMINI_API_KEY"), "Content-Type": "application/json"},
|
|
json=body, timeout=120,
|
|
)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Gemini {resp.status_code}: {resp.text[:200]}")
|
|
return
|
|
b64 = _extract_b64(resp.json())
|
|
if not b64:
|
|
webhook_update_task(task_id, "failed", 0, "", error="Gemini 응답에 이미지 없음")
|
|
return
|
|
url = save_b64_png(task_id, b64)
|
|
webhook_update_task(task_id, "succeeded", 100, "완료", image_url=url)
|
|
except Exception as e:
|
|
logger.exception("nano_banana task=%s 실패", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
```
|
|
|
|
- [ ] **Step 4: 통과 확인 + 커밋**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/test_nano_banana.py -v` → PASS
|
|
```bash
|
|
git add services/image-render/providers/nano_banana.py services/image-render/tests/test_nano_banana.py
|
|
git commit -m "feat(image-render): nano_banana (Gemini Flash Image) provider"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: provider flux — ComfyUI 로컬
|
|
|
|
**Files:**
|
|
- Create: `services/image-render/providers/flux.py`
|
|
- Create: `services/image-render/providers/flux_workflow.json` (ComfyUI에서 export한 FLUX text2img workflow — engineer가 ComfyUI UI에서 "Save (API Format)"로 생성)
|
|
- Test: `services/image-render/tests/test_flux.py`
|
|
|
|
> **GPU 가드**: FLUX는 RTX 5070 Ti VRAM을 Chronos(장중 7GB)·video-render와 공유. 장중(09:00~15:30)에는 GPU 충돌 위험 → 환경변수 `FLUX_BLOCK_TRADING_HOURS=1`이면 장중 거부(API provider로 폴백 권장).
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성**
|
|
|
|
`services/image-render/tests/test_flux.py`:
|
|
```python
|
|
import providers.flux as fx
|
|
|
|
def test_blocked_during_trading_hours(monkeypatch):
|
|
monkeypatch.setenv("FLUX_BLOCK_TRADING_HOURS", "1")
|
|
monkeypatch.setattr(fx, "_is_trading_hours", lambda: True)
|
|
calls = []
|
|
monkeypatch.setattr(fx, "webhook_update_task", lambda *a, **k: calls.append((a, k)))
|
|
fx.run_flux_generation("t1", {"prompt": "a cat"})
|
|
assert calls[-1][0][1] == "failed"
|
|
assert "장중" in calls[-1][1]["error"]
|
|
|
|
def test_success_polls_history_and_saves(monkeypatch):
|
|
monkeypatch.setattr(fx, "_is_trading_hours", lambda: False)
|
|
calls = []
|
|
monkeypatch.setattr(fx, "webhook_update_task", lambda *a, **k: calls.append((a, k)))
|
|
monkeypatch.setattr(fx, "_load_workflow", lambda prompt, size: {"3": {}})
|
|
monkeypatch.setattr(fx, "_submit_prompt", lambda wf: "pid-1")
|
|
monkeypatch.setattr(fx, "_poll_image_b64", lambda pid: "ZmFrZQ==")
|
|
monkeypatch.setattr(fx, "save_b64_png", lambda tid, b64: "/media/image/t1.png")
|
|
fx.run_flux_generation("t1", {"prompt": "a cat"})
|
|
assert [c for c in calls if c[0][1] == "succeeded"]
|
|
```
|
|
|
|
- [ ] **Step 2: 실패 확인**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/test_flux.py -v`
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: flux.py 작성**
|
|
|
|
`services/image-render/providers/flux.py`:
|
|
```python
|
|
"""FLUX 로컬 — ComfyUI HTTP API.
|
|
|
|
POST {COMFYUI_URL}/prompt (workflow JSON) → prompt_id
|
|
GET {COMFYUI_URL}/history/{prompt_id} → outputs → image filename
|
|
GET {COMFYUI_URL}/view?filename=... → PNG bytes → b64
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64, json, logging, os, time
|
|
from datetime import datetime, timezone, timedelta
|
|
import requests
|
|
|
|
from nas_client import webhook_update_task
|
|
from providers._media import save_b64_png
|
|
|
|
logger = logging.getLogger(__name__)
|
|
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188")
|
|
WORKFLOW_PATH = os.path.join(os.path.dirname(__file__), "flux_workflow.json")
|
|
POLL_INTERVAL = 2
|
|
POLL_MAX = 120
|
|
|
|
|
|
def _is_trading_hours() -> bool:
|
|
kst = timezone(timedelta(hours=9))
|
|
now = datetime.now(kst)
|
|
if now.weekday() >= 5:
|
|
return False
|
|
return (now.hour, now.minute) >= (9, 0) and (now.hour, now.minute) <= (15, 30)
|
|
|
|
|
|
def _load_workflow(prompt: str, size: str) -> dict:
|
|
with open(WORKFLOW_PATH, encoding="utf-8") as f:
|
|
wf = json.load(f)
|
|
# CLIPTextEncode 노드의 text를 prompt로 치환 (workflow에 "%PROMPT%" placeholder 사용)
|
|
raw = json.dumps(wf).replace("%PROMPT%", prompt.replace('"', "'"))
|
|
return json.loads(raw)
|
|
|
|
|
|
def _submit_prompt(workflow: dict) -> str:
|
|
r = requests.post(f"{COMFYUI_URL}/prompt", json={"prompt": workflow}, timeout=30)
|
|
r.raise_for_status()
|
|
return r.json()["prompt_id"]
|
|
|
|
|
|
def _poll_image_b64(prompt_id: str):
|
|
for _ in range(POLL_MAX):
|
|
h = requests.get(f"{COMFYUI_URL}/history/{prompt_id}", timeout=10)
|
|
data = h.json().get(prompt_id)
|
|
if data and data.get("outputs"):
|
|
for node_out in data["outputs"].values():
|
|
for img in node_out.get("images", []):
|
|
view = requests.get(f"{COMFYUI_URL}/view",
|
|
params={"filename": img["filename"], "subfolder": img.get("subfolder", ""), "type": img.get("type", "output")},
|
|
timeout=30)
|
|
view.raise_for_status()
|
|
return base64.b64encode(view.content).decode()
|
|
time.sleep(POLL_INTERVAL)
|
|
return None
|
|
|
|
|
|
def run_flux_generation(task_id: str, params: dict) -> None:
|
|
try:
|
|
if os.getenv("FLUX_BLOCK_TRADING_HOURS") == "1" and _is_trading_hours():
|
|
webhook_update_task(task_id, "failed", 0, "", error="장중 GPU 보호 — FLUX 거부 (API provider 사용 권장)")
|
|
return
|
|
webhook_update_task(task_id, "processing", 10, "FLUX (ComfyUI) 생성 중...")
|
|
wf = _load_workflow(params["prompt"], params.get("size") or "1024x1024")
|
|
pid = _submit_prompt(wf)
|
|
b64 = _poll_image_b64(pid)
|
|
if not b64:
|
|
webhook_update_task(task_id, "failed", 0, "", error="ComfyUI 타임아웃 또는 출력 없음")
|
|
return
|
|
url = save_b64_png(task_id, b64)
|
|
webhook_update_task(task_id, "succeeded", 100, "완료", image_url=url)
|
|
except Exception as e:
|
|
logger.exception("flux task=%s 실패", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
```
|
|
|
|
- [ ] **Step 4: 통과 확인**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/test_flux.py -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: flux_workflow.json placeholder 생성 안내**
|
|
|
|
`flux_workflow.json`은 ComfyUI UI에서 FLUX text2img 그래프 구성 후 **"Save (API Format)"**로 export한 JSON. CLIPTextEncode 노드의 text 값을 `"%PROMPT%"`로 수동 치환해 저장. (engineer가 ComfyUI 환경에서 직접 생성 — 코드로 생성 불가)
|
|
|
|
- [ ] **Step 6: 커밋**
|
|
|
|
```bash
|
|
git add services/image-render/providers/flux.py services/image-render/tests/test_flux.py
|
|
git commit -m "feat(image-render): flux (ComfyUI 로컬) provider + GPU 장중 가드"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: image-render worker — Redis BLPOP dispatch
|
|
|
|
**Files:**
|
|
- Create: `services/image-render/worker.py`
|
|
- Test: `services/image-render/tests/test_worker.py`
|
|
|
|
복제 원본: `services/video-render/worker.py`. 치환: `queue:video-render`→`queue:image-render`, dispatch table을 3 provider로, provider import 교체.
|
|
|
|
- [ ] **Step 1: 실패 테스트 작성**
|
|
|
|
`services/image-render/tests/test_worker.py`:
|
|
```python
|
|
import worker
|
|
|
|
def test_dispatch_routes_to_provider(monkeypatch):
|
|
called = {}
|
|
monkeypatch.setattr(worker, "run_gpt_image_generation", lambda tid, p: called.setdefault("gpt", (tid, p)))
|
|
worker._dispatch({"job_type": "gpt_image_generation", "task_id": "t1", "params": {"prompt": "x"}})
|
|
assert called["gpt"][0] == "t1"
|
|
|
|
def test_dispatch_unknown_job_type_reports_failed(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(worker, "webhook_update_task", lambda *a, **k: calls.append((a, k)))
|
|
worker._dispatch({"job_type": "midjourney_generation", "task_id": "t9", "params": {}})
|
|
assert calls[-1][0][1] == "failed"
|
|
```
|
|
|
|
- [ ] **Step 2: 실패 확인**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/test_worker.py -v`
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: worker.py 작성**
|
|
|
|
`services/image-render/worker.py`:
|
|
```python
|
|
"""Redis BLPOP worker — queue:image-render → job_type dispatch → NAS webhook.
|
|
|
|
queue:paused 가 set이면 대기 (task-watcher가 박재오 활동 감지 시 set).
|
|
video-render worker.py 패턴 — string-based dispatch + getattr (테스트 patch 호환).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio, json, logging, os, sys
|
|
import redis.asyncio as aioredis
|
|
|
|
from nas_client import webhook_update_task
|
|
from providers.gpt_image import run_gpt_image_generation
|
|
from providers.nano_banana import run_nano_banana_generation
|
|
from providers.flux import run_flux_generation
|
|
|
|
logger = logging.getLogger(__name__)
|
|
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
|
QUEUE_KEY = "queue:image-render"
|
|
PAUSED_KEY = "queue:paused"
|
|
|
|
_DISPATCH_TABLE = {
|
|
"gpt_image_generation": "run_gpt_image_generation",
|
|
"nano_banana_generation": "run_nano_banana_generation",
|
|
"flux_generation": "run_flux_generation",
|
|
}
|
|
|
|
|
|
def _dispatch(payload: dict) -> None:
|
|
job_type = payload.get("job_type", "")
|
|
task_id = payload.get("task_id", "")
|
|
params = payload.get("params", {})
|
|
fn_name = _DISPATCH_TABLE.get(job_type)
|
|
if fn_name is None:
|
|
logger.error("unknown job_type=%s task=%s", job_type, task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"unknown job_type: {job_type}")
|
|
return
|
|
try:
|
|
fn = getattr(sys.modules[__name__], fn_name)
|
|
except AttributeError:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"internal dispatch error: {fn_name}")
|
|
return
|
|
fn(task_id, params)
|
|
|
|
|
|
async def worker_loop():
|
|
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
|
logger.info("image-render worker started (queue=%s)", QUEUE_KEY)
|
|
while True:
|
|
try:
|
|
if await redis.get(PAUSED_KEY):
|
|
await asyncio.sleep(2)
|
|
continue
|
|
item = await redis.blpop(QUEUE_KEY, timeout=5)
|
|
if item is None:
|
|
continue
|
|
payload = json.loads(item[1])
|
|
await asyncio.to_thread(_dispatch, payload)
|
|
except Exception:
|
|
logger.exception("worker_loop iteration 실패")
|
|
await asyncio.sleep(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(level=logging.INFO)
|
|
asyncio.run(worker_loop())
|
|
```
|
|
|
|
- [ ] **Step 4: 통과 확인 + 커밋**
|
|
|
|
Run: `cd services/image-render && python -m pytest tests/ -v` → ALL PASS
|
|
```bash
|
|
git add services/image-render/worker.py services/image-render/tests/test_worker.py
|
|
git commit -m "feat(image-render): Redis BLPOP worker + 3 provider dispatch"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: image-render main + Dockerfile + compose entry
|
|
|
|
**Files:**
|
|
- Create: `services/image-render/main.py`, `Dockerfile`, `requirements.txt`, `env.example`
|
|
- Modify: `services/docker-compose.yml` (image-render 서비스 추가)
|
|
|
|
- [ ] **Step 1: main.py 작성 (video-render main 패턴 — lifespan에서 worker_loop 태스크)**
|
|
|
|
`services/image-render/main.py`:
|
|
```python
|
|
"""image-render FastAPI — health + 백그라운드 worker_loop."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio, logging
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI
|
|
from worker import worker_loop
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
task = asyncio.create_task(worker_loop())
|
|
yield
|
|
task.cancel()
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"ok": True, "service": "image-render"}
|
|
```
|
|
|
|
- [ ] **Step 2: requirements.txt**
|
|
|
|
`services/image-render/requirements.txt`:
|
|
```
|
|
fastapi==0.115.0
|
|
uvicorn[standard]==0.30.6
|
|
redis==5.0.8
|
|
httpx==0.27.2
|
|
requests==2.32.3
|
|
```
|
|
|
|
- [ ] **Step 3: Dockerfile**
|
|
|
|
`services/image-render/Dockerfile`:
|
|
```dockerfile
|
|
FROM python:3.12-slim
|
|
WORKDIR /app
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
COPY . .
|
|
EXPOSE 8000
|
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
|
```
|
|
|
|
- [ ] **Step 4: docker-compose.yml에 image-render 추가**
|
|
|
|
> ⚠️ **포트 주의**: web-ai/services 현재 점유 — 18710/18711/18712 + **18713(task-watcher)**. image-render는 **18714** 사용 (18713 충돌 회피).
|
|
|
|
`services/docker-compose.yml` (video-render 블록 다음, GPU·NAS SMB 마운트는 video-render와 동일하게):
|
|
```yaml
|
|
image-render:
|
|
build: ./image-render
|
|
container_name: image-render
|
|
restart: unless-stopped
|
|
ports:
|
|
- "18714:8000"
|
|
environment:
|
|
- REDIS_URL=redis://192.168.45.54:6379
|
|
- NAS_BASE_URL=http://192.168.45.54:18802
|
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
|
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
|
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
|
- COMFYUI_URL=${COMFYUI_URL:-http://host.docker.internal:8188}
|
|
- FLUX_BLOCK_TRADING_HOURS=1
|
|
- IMAGE_MEDIA_ROOT=/mnt/nas/webpage/data/image
|
|
volumes:
|
|
- /mnt/nas/webpage/data/image:/mnt/nas/webpage/data/image
|
|
```
|
|
|
|
- [ ] **Step 5: 빌드 + 헬스 + 커밋**
|
|
|
|
```bash
|
|
docker compose -f services/docker-compose.yml build image-render
|
|
docker compose -f services/docker-compose.yml up -d image-render
|
|
curl http://localhost:18714/health
|
|
git add services/image-render/main.py services/image-render/Dockerfile services/image-render/requirements.txt services/image-render/env.example services/docker-compose.yml
|
|
git commit -m "feat(image-render): main + Dockerfile + compose entry"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: E2E 검증 (curl)
|
|
|
|
- [ ] **Step 1: 이미지 생성 요청**
|
|
|
|
```bash
|
|
curl -X POST http://localhost:18802/api/image/generate \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"provider":"gpt_image","prompt":"a cute white desktop robot, big LED eyes, studio lighting"}'
|
|
```
|
|
Expected: `{"task_id":"<uuid>","provider":"gpt_image"}`
|
|
|
|
- [ ] **Step 2: 폴링**
|
|
|
|
```bash
|
|
curl http://localhost:18802/api/image/tasks/<task_id>
|
|
```
|
|
Expected: status가 queued → processing → succeeded로 전이, `image_url` 채워짐
|
|
|
|
- [ ] **Step 3: 이미지 확인**
|
|
|
|
```bash
|
|
curl -I http://localhost:8080<image_url> # nginx /media/image/ alias
|
|
```
|
|
Expected: 200 + image/png
|
|
|
|
- [ ] **Step 4: nano_banana·flux도 동일 검증** (각 provider key·ComfyUI 준비 필요)
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage:**
|
|
- image-lab (NAS): Task 1~5 ✅ | image-render (Windows): Task 6~11 ✅ | E2E: Task 12 ✅
|
|
- provider 3종(gpt_image/nano_banana/flux): Task 7/8/9 ✅
|
|
- nginx 3-layer 차단: Task 5 Step 4 ✅
|
|
- queue:paused 존중: Task 10 worker_loop ✅
|
|
- 비범위(keyframe/1→N/서버저장): 본 plan 미포함 (2·3단계) — spec과 일치 ✅
|
|
- 프론트 /studio: **Plan 2에서** (본 plan은 백엔드만)
|
|
|
|
**Placeholder scan:** flux_workflow.json은 ComfyUI export 산출물로 코드 생성 불가 항목 — Task 9 Step 5에 명확한 생성 절차 명시 (placeholder 아님). nginx 정확 파일은 grep으로 확정 지시.
|
|
|
|
**Type consistency:** `webhook_update_task(task_id, status, progress, message, image_url=, error=)` — nas_client(Task 6)·3 provider(7/8/9)·worker(10) 전부 동일 시그니처. job_type `{provider}_generation` — image-lab main(Task 4)·worker dispatch table(Task 10) 일치. `save_b64_png(task_id, b64)` — _media(Task 7)·3 provider 일치.
|
|
|
|
**미해결(구현 중 확정):** image-lab 포트 18802(compose 충돌 확인) / flux_workflow.json(ComfyUI export) / nano_banana 모델명(gemini-2.5-flash-image, API 응답으로 검증).
|