8 Commits

Author SHA1 Message Date
2e042e18c5 fix(image-lab): env 변수를 다른 -lab과 동일하게 정렬 (TZ + :- defaults) 2026-05-23 11:51:38 +09:00
83e74ad1f4 fix(image-lab): volume mount을 video-lab과 동일한 ${RUNTIME_PATH}/data/image로 통일 2026-05-23 11:48:24 +09:00
b70caddff1 feat(image-lab): Dockerfile + compose entry + scripts 6위치 + nginx 차단
Task 5 of Video Studio backend plan. Wires image-lab Python code (T1-T4)
into NAS Docker infrastructure on port 18802.

- image-lab/Dockerfile (python:3.12-slim + uvicorn)
- image-lab/requirements.txt (fastapi, redis, httpx)
- image-lab/env.example (INTERNAL_API_KEY, IMAGE_DATA_DIR, REDIS_URL, CORS)
- docker-compose.yml: image-lab service block (port 18802, redis depends_on,
  healthcheck, volume ${RUNTIME_PATH}/image-data:/app/data) + frontend
  depends_on entry
- scripts/deploy-nas.sh: SERVICES += image-lab
- scripts/deploy.sh: BUILD_TARGETS/CONTAINER_NAMES/HEALTH_ENDPOINTS += image-lab,
  DATA_DIRS += image
- nginx/default.conf: /api/internal/image/ 3-layer block (IP allowlist +
  deny all + X-Internal-Key forward) mirroring /api/internal/video/

Plan-B-Video lesson: 6-location registration enforced per
feedback_nas_deploy_paths.md rule 3 to avoid 'transferring dockerfile: 2B'
deploy failure.

Tests: image-lab pytest 11 passed (no regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:46:45 +09:00
d6e34973a4 feat(image-lab): generate/tasks/providers 엔드포인트 (video-lab 복제) 2026-05-23 11:41:47 +09:00
7007c90665 feat(image-lab): /api/internal/image/update webhook (video-lab 복제) 2026-05-23 11:37:33 +09:00
ca7a502514 feat(image-lab): verify_internal_key (video-lab 복제) 2026-05-23 11:34:03 +09:00
dc471ecc60 feat(image-lab): image_tasks 테이블 + CRUD (video-lab 복제) 2026-05-23 11:31:02 +09:00
e91715bf2c docs(plan): video-studio Plan 1 — image-render 포트 18714(task-watcher 충돌 회피) + scripts 6위치 등재 step 추가 2026-05-23 11:28:21 +09:00
18 changed files with 1827 additions and 5 deletions

View File

@@ -113,6 +113,28 @@ services:
timeout: 5s
retries: 3
image-lab:
build: ./image-lab
container_name: image-lab
restart: unless-stopped
ports:
- "18802:8000"
environment:
- TZ=${TZ:-Asia/Seoul}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- IMAGE_DATA_DIR=/app/data
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH}/data/image:/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
insta-lab:
build:
context: ./insta-lab
@@ -289,6 +311,7 @@ services:
- packs-lab
- travel-proxy
- video-lab
- image-lab
ports:
- "8080:80"
volumes:

File diff suppressed because it is too large Load Diff

7
image-lab/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
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"]

View File

13
image-lab/app/auth.py Normal file
View File

@@ -0,0 +1,13 @@
"""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")

83
image-lab/app/db.py Normal file
View File

@@ -0,0 +1,83 @@
"""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

View File

@@ -0,0 +1,52 @@
"""Windows image-render → NAS image-lab internal webhook.
POST /api/internal/image/update
- X-Internal-Key 인증 필수
- image_tasks row update (status, progress, message, image_url, error)
"""
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}

113
image-lab/app/main.py Normal file
View File

@@ -0,0 +1,113 @@
"""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
import logging
import os
import 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():
"""3 provider 항상 노출 (key 누락은 worker가 failed 보고)."""
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
# Provider 별 추가 키는 extra 허용
extra: Optional[Dict[str, Any]] = None
class Config:
extra = "allow"
async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
"""Redis queue:image-render에 push."""
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):
"""이미지 생성 — Redis 큐로 Windows image-render에 위임."""
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" # gpt_image_generation, nano_banana_generation, flux_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

4
image-lab/env.example Normal file
View File

@@ -0,0 +1,4 @@
INTERNAL_API_KEY=replace-me
IMAGE_DATA_DIR=/app/data
CORS_ALLOW_ORIGINS=http://localhost:3007,http://localhost:8080
REDIS_URL=redis://redis:6379

View File

@@ -0,0 +1,5 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
pydantic==2.9.2
redis==5.0.8
httpx==0.27.2

View File

View File

@@ -0,0 +1,19 @@
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

View File

@@ -0,0 +1,29 @@
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

View File

@@ -0,0 +1,38 @@
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

View File

@@ -0,0 +1,43 @@
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)
db.init_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"

View File

@@ -276,6 +276,26 @@ server {
proxy_pass http://$video_internal_backend$request_uri;
}
# Video Studio — Windows image-render → NAS image-lab internal webhook
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
# Layer 3: X-Internal-Key (FastAPI dependency)
location /api/internal/image/ {
allow 192.168.45.0/24; # LAN 화이트리스트
allow 100.64.0.0/10; # Tailscale CGNAT
allow 127.0.0.1; # NAS 내부
deny all;
resolver 127.0.0.11 valid=10s;
set $image_internal_backend image-lab:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Internal-Key $http_x_internal_key;
proxy_pass http://$image_internal_backend$request_uri;
}
# portfolio API (Stock) — trailing slash 유무 모두 매칭
location /api/portfolio {
proxy_http_version 1.1;

View File

@@ -2,7 +2,7 @@
set -euo pipefail
# ── 서비스 목록 (한 곳에서만 관리) ──
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab nginx scripts"
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab nginx scripts"
# 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then

View File

@@ -15,15 +15,15 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
# ── 서비스 목록 (한 곳에서만 관리) ──
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab frontend"
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab frontend"
# 컨테이너 이름 (고아 정리용 — blog-lab은 폐기 대상으로 정리 리스트에 유지)
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy video-lab frontend"
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy video-lab image-lab frontend"
# Infra 서비스 (image-based, 영속 데이터 보존을 위해 stop/rm 없이 up만)
INFRA_SERVICES="redis"
# 헬스체크 대상
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab redis"
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab redis"
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
DATA_DIRS="music stock insta realestate agent-office personal video"
DATA_DIRS="music stock insta realestate agent-office personal video image"
# 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then