kintex/tools/nanobanana/worker.py
zio 0f91dcf4ae feat(ai): ReRoomAI image-to-image booth photo restyling (M5 pipeline reuse)
Reference photo upload (magic-byte check, 10MB, server-named files) +
reroom render job on the existing RenderJob queue/callback/WS pipeline.
Worker gains R1 reroom mode: 4-part prompt with fixed preserve-lock
(architecture + camera perspective) and style dictionary (6 styles,
JSON single source); NL instruction injects into replace-part only.
Booth Design Studio gets a photo-mockup section with client 1024px
downscale, style cards and Before/After slider (watermark enforced).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:25:23 +09:00

604 lines
26 KiB
Python

"""나노바나나 RenderJob 워커 사이드카 (스켈레톤).
Spring Boot 백엔드가 Redis 큐에 넣은 RenderJob 을 소비해, PLANNING §6-2 scene 을
사실화 사진(S1~S5,S7 생성형) 또는 배선 오버레이(S6 래스터 합성)로 렌더하고,
결과 이미지 + 메타데이터를 오브젝트 스토리지에 적재한 뒤 완료 이벤트를 발행한다.
설계 기준:
- PLANNING §6(나노바나나 파이프라인 v1.2) · §8(Python 워커 사이드카 아키텍처).
- IMPLEMENTATION_BACKLOG S-4(워커 골격) / M5-2(생성) / M5-3(S6 래스터).
- 계약 단일 출처: `_workspace/01_worker_contract.md`.
- 호출 스택·프롬프트·방어 로직은 `client.py`(ReRoomAI 검증 패턴) 재사용.
핵심 성질(운영 불변식):
1. G1 게이트 — 실 Gemini 호출은 env `NANOBANANA_LIVE=1` + `GEMINI_API_KEY` 있을 때만.
기본은 목/degraded 모드(키·네트워크 없이 플레이스홀더 이미지 + 정상 메타데이터).
2. 지연 연결 — Redis 미기동이어도 import·process_job() 직접 호출은 성립.
3. S6 은 생성형 아님 — 항상 로컬 PIL 결정적 래스터 합성(좌표 정합).
4. 비밀 미노출 — 키/IP/스택트레이스를 이벤트·로그·에러에 기록하지 않는다.
CLI:
python -m tools.nanobanana.worker # 큐 소비 루프(BLPOP)
python -m tools.nanobanana.worker --smoke # 무네트워크 스모크(목 잡 1건 처리)
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
# ----------------------------------------------------------------------------
# client.py 재사용 — 모듈 실행(`-m`)·직접 실행 양쪽 지원
# ----------------------------------------------------------------------------
try: # 패키지 컨텍스트 (python -m tools.nanobanana.worker)
from .client import ( # type: ignore
MODEL_NAME,
SHOT_PRESETS,
WIRING_COLORS,
GeneratedImage,
NanoBananaAuthError,
NanoBananaError,
NanoBananaQuotaError,
NanoBananaSafetyError,
build_booth_prompt,
build_metadata,
render_wiring_overlay_raster,
)
except ImportError: # 스크립트 컨텍스트 (python worker.py) — 동일 디렉터리 임포트
sys.path.insert(0, str(Path(__file__).resolve().parent))
from client import ( # type: ignore
MODEL_NAME,
SHOT_PRESETS,
WIRING_COLORS,
GeneratedImage,
NanoBananaAuthError,
NanoBananaError,
NanoBananaQuotaError,
NanoBananaSafetyError,
build_booth_prompt,
build_metadata,
render_wiring_overlay_raster,
)
# ----------------------------------------------------------------------------
# 환경 설정 (계약 §7)
# ----------------------------------------------------------------------------
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
QUEUE_KEY = os.environ.get("NANOBANANA_QUEUE", "kintex:renderjob:queue")
EVENT_CHANNEL = os.environ.get("NANOBANANA_EVENT_CHANNEL", "kintex:renderjob:events")
OUTPUT_DIR = os.environ.get("NANOBANANA_OUTPUT_DIR", "output/visualizations")
#: BLPOP 폴링 간격(초) — None 무한 대기 대신 짧은 폴링으로 graceful stop 가능.
BLPOP_TIMEOUT_S = 5
def is_live() -> bool:
"""G1 게이트: 실 Gemini 호출 조건(PLANNING R12).
NANOBANANA_LIVE=1 그리고 GEMINI_API_KEY 가 있을 때만 True.
그 외에는 목/degraded 모드(기본). 키 값 자체는 참조만 하고 반환·로그하지 않는다.
"""
flag = os.environ.get("NANOBANANA_LIVE", "").strip().lower() in ("1", "true", "yes", "on")
has_key = bool(os.environ.get("GEMINI_API_KEY"))
return flag and has_key
# ----------------------------------------------------------------------------
# 목/degraded 플레이스홀더 이미지 (키·네트워크 불필요)
# ----------------------------------------------------------------------------
#: PIL 부재 시 폴백용 1x1 회색 PNG(유효 최소 이미지).
_MINIMAL_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
def _placeholder_png(lines: list[str], size: tuple[int, int] = (1024, 640)) -> tuple[bytes, str]:
"""플레이스홀더 PNG 생성. PIL 있으면 안내 문구+워터마크 렌더, 없으면 최소 PNG.
생성형이 아니라 파이프라인 구조 검증용 자리표시자다(G1 미승인/목 모드).
"""
try:
import io
from PIL import Image, ImageDraw # type: ignore
except ImportError:
return _MINIMAL_PNG, "image/png"
W, H = size
im = Image.new("RGB", (W, H), (34, 38, 46))
draw = ImageDraw.Draw(im)
# 안내 문구(중앙)
y = H // 2 - 12 * len(lines)
for ln in lines:
draw.text((40, y), ln, fill=(210, 214, 222))
y += 24
# 워터마크(하단) — §6-6 정책과 동일 문구
draw.rectangle([0, H - 34, W, H], fill=(20, 22, 28))
draw.text((12, H - 26), "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음 (MOCK)", fill=(255, 214, 90))
buf = io.BytesIO()
im.save(buf, format="PNG")
return buf.getvalue(), "image/png"
# ============================================================================
# RenderJob / RenderResult 계약 객체 (계약 §2/§3)
# ============================================================================
def _first(d: dict, *keys: str, default=None):
"""여러 키(snake_case/camelCase) 중 먼저 값이 있는 것 반환. 백엔드 payload 정합 보강."""
for k in keys:
v = d.get(k)
if v is not None:
return v
return default
@dataclass
class RenderJob:
job_id: str
shot_preset: str = "S1"
event_id: Optional[str] = None
booth_id: Optional[str] = None
scene: dict = field(default_factory=dict)
seed: Optional[int] = None
reference_image: Optional[str] = None
layers: Optional[list[str]] = None
wiring: Optional[dict] = None
hall_dims_m: Optional[list[float]] = None
options: dict = field(default_factory=dict)
# ReRoomAI 방식 사진 시안(소유자 지시 13.2) — mode/style/instruction.
mode: Optional[str] = None
style: Optional[str] = None
instruction: Optional[str] = None
@classmethod
def from_dict(cls, d: dict) -> "RenderJob":
# 백엔드는 camelCase(jobId/shotPreset…)로 발행 — snake_case 워커 계약과 양립하도록 관대 파싱.
job_id = _first(d, "job_id", "jobId")
if not job_id:
raise NanoBananaError("RenderJob.job_id 누락")
return cls(
job_id=str(job_id),
shot_preset=_first(d, "shot_preset", "shotPreset", default="S1"),
event_id=_first(d, "event_id", "eventId"),
booth_id=_first(d, "booth_id", "boothId"),
scene=_first(d, "scene", default={}) or {},
seed=_first(d, "seed"),
# reference_image=워커 소비용 로컬 경로, referenceImageUrl=프론트 표시용 URL.
reference_image=_first(d, "reference_image", "referenceImage", "referenceImageUrl"),
layers=_first(d, "layers"),
wiring=_first(d, "wiring"),
hall_dims_m=_first(d, "hall_dims_m", "hallDimsM"),
options=_first(d, "options", default={}) or {},
mode=_first(d, "mode"),
style=_first(d, "style"),
instruction=_first(d, "instruction"),
)
@dataclass
class RenderResult:
job_id: str
status: str # DONE | FAILED
image_ref: Optional[dict] = None
meta: dict = field(default_factory=dict)
error: Optional[dict] = None
# ============================================================================
# 오브젝트 스토리지 (기본=로컬 FS degraded 어댑터, 계약 §4)
# ============================================================================
class ObjectStore:
"""이미지 + 사이드카 메타데이터 적재. 기본 구현은 로컬 파일시스템.
운영 시 S3/GCS 어댑터로 교체하되 save_image() 반환 계약(§4)은 유지한다.
"""
def __init__(self, root: str = OUTPUT_DIR, backend: str = "local"):
self.root = Path(root)
self.backend = backend
@staticmethod
def key_for(job: RenderJob) -> str:
ev = job.event_id or "no-event"
booth = job.booth_id or "no-booth"
return f"{ev}/{booth}/{job.job_id}_{job.shot_preset}.png"
def save_image(self, image: GeneratedImage, key: str) -> dict:
"""GeneratedImage.save() 로 이미지+사이드카(.meta.json) 기록 후 참조 반환."""
path = self.root / key
image.save(path) # 사이드카 .meta.json 동시 기록(결정적, B-03)
return {
"backend": self.backend,
"key": key,
"uri": path.resolve().as_uri(),
"sidecar_key": key + ".meta.json",
"content_type": image.mime_type,
}
# ============================================================================
# 완료 이벤트 발행 (계약 §3) — Redis pub/sub 스텁(지연 연결)
# ============================================================================
class EventPublisher:
"""완료/실패 이벤트를 Redis 채널에 발행. 백엔드가 구독→WebSocket 릴레이.
Redis 미연결 시에도 예외로 파이프라인을 멈추지 않는다(로그만). 비밀 미노출.
"""
def __init__(self, redis_url: str = REDIS_URL, channel: str = EVENT_CHANNEL):
self.redis_url = redis_url
self.channel = channel
self._r = None # 지연 연결
def _redis(self):
if self._r is None:
import redis # type: ignore # 지연 임포트 — 미설치여도 import 성립
self._r = redis.from_url(self.redis_url)
return self._r
def publish(self, event: dict) -> None:
payload = json.dumps(event, ensure_ascii=False)
try:
self._redis().publish(self.channel, payload)
except Exception as e: # noqa: BLE001 — 발행 실패가 렌더를 무효화하지 않음
# 메시지 요약만(키/스택트레이스 미기록)
print(
f"[event] publish skipped (redis unavailable): {type(e).__name__}", file=sys.stderr
)
# 항상 콘솔 관측(백엔드 부재 스모크에서도 이벤트 흐름 확인)
print(f"[event] {event.get('type')} job={event.get('job_id')} status={event.get('status')}")
# ============================================================================
# 워커
# ============================================================================
class RenderWorker:
"""RenderJob 소비 루프 + 단건 처리(process_job). 지연 연결로 구조만으로 성립."""
def __init__(
self,
redis_url: str = REDIS_URL,
queue_key: str = QUEUE_KEY,
store: Optional[ObjectStore] = None,
publisher: Optional[EventPublisher] = None,
):
self.redis_url = redis_url
self.queue_key = queue_key
self.store = store or ObjectStore()
self.publisher = publisher or EventPublisher(redis_url)
self._r = None # 지연 연결
self._stop = False
self._client = None # NanoBananaClient 지연 생성(실 호출 시에만)
# ------------------------------------------------------------------
# 렌더 디스패치
# ------------------------------------------------------------------
def _render(self, job: RenderJob) -> GeneratedImage:
"""샷 프리셋에 따라 (a) S6 래스터 합성, (b) ReRoom 사진 시안, (c) 생성형/목 렌더로 분기."""
preset = SHOT_PRESETS.get(job.shot_preset)
if preset is None:
raise NanoBananaError(f"알 수 없는 샷 프리셋: {job.shot_preset} (S1~S7,R1)")
# (a) S6 배선 오버레이 — 생성형 아님, 항상 로컬 PIL 결정적 합성(M5-3)
if not preset.get("generative", True):
return self._render_wiring(job)
# (b) ReRoomAI 방식 사진 시안(참조 사진 골격 보존 image-to-image) — 소유자 지시 13.2
if self._is_reroom(job, preset):
if is_live():
return self._render_live_reroom(job) # G1 승인 경로
return self._render_mock_reroom(job)
# (c) 생성형 샷(S1~S5,S7)
if is_live():
return self._render_live(job) # G1 승인 경로
return self._render_mock(job) # 기본: 목/degraded
@staticmethod
def _is_reroom(job: RenderJob, preset: dict) -> bool:
"""ReRoom 사진 시안 여부: 프리셋 reroom 플래그 / mode / style·instruction 존재."""
return bool(
preset.get("reroom")
or (job.mode or "").lower() == "reroom"
or job.style
or job.instruction
)
def _render_live_reroom(self, job: RenderJob) -> GeneratedImage:
"""G1 승인 경로 — 실 Gemini image-to-image(참조 사진 필수)."""
if not job.reference_image:
raise NanoBananaError("사진 시안(ReRoom)에는 참조 이미지가 필요합니다.")
if self._client is None:
try:
from .client import NanoBananaClient # type: ignore
except ImportError:
from client import NanoBananaClient # type: ignore
self._client = NanoBananaClient(model=MODEL_NAME)
img = self._client.render_reroom(
reference_image=job.reference_image,
style=job.style or "modern",
instruction=job.instruction,
seed=job.seed,
shot_preset=job.shot_preset,
)
img.metadata.setdefault("render_path", "reroom_image_to_image")
img.metadata["live"] = True
img.metadata["degraded"] = False
return img
def _render_mock_reroom(self, job: RenderJob) -> GeneratedImage:
"""기본 경로 — ReRoom 목/degraded. 프롬프트·메타데이터는 실제 경로와 동일하게 조립."""
try:
from .client import build_reroom_prompt, REROOM_STYLES # type: ignore
except ImportError:
from client import build_reroom_prompt, REROOM_STYLES # type: ignore
try:
prompt = build_reroom_prompt(job.style or "modern", job.instruction)
except Exception: # noqa: BLE001
prompt = "[mock] reroom prompt build skipped"
style_label = (REROOM_STYLES.get(job.style or "modern") or {}).get("label", job.style or "-")
meta = build_metadata({"reroom": True, "style": job.style}, "mock", job.shot_preset, job.seed)
meta["render_path"] = "reroom_image_to_image"
meta["reroom"] = True
meta["style"] = job.style
meta["live"] = False
meta["degraded"] = True
data, mime = _placeholder_png(
[
"KINTEX 나노바나나 — ReRoom 사진 시안 (MOCK / DEGRADED)",
f"style: {style_label}",
f"booth: {job.booth_id or '-'} event: {job.event_id or '-'}",
f"ref: {'있음' if job.reference_image else '없음'}",
"G1 미승인/목 모드 — 실 Gemini 호출 없이 생성한 자리표시자",
]
)
return GeneratedImage(data=data, mime_type=mime, prompt_used=prompt, metadata=meta)
def _render_wiring(self, job: RenderJob) -> GeneratedImage:
"""S6: scene.wiring/최상위 wiring → 좌표 정합 래스터 오버레이(client 재사용)."""
scene_inner = job.scene.get("scene", job.scene)
wiring = job.wiring or scene_inner.get("wiring") or {}
if not wiring:
raise NanoBananaError("S6 배선 오버레이에 wiring 데이터가 필요합니다.")
# 좌표계 기준(m): job.hall_dims_m → booth.size_m → hall.dims_m → 기본
booth = scene_inner.get("booth", {}) or {}
hall = scene_inner.get("hall", {}) or {}
dims = job.hall_dims_m or booth.get("size_m") or hall.get("dims_m") or [10, 10]
hall_dims_m = (float(dims[0]), float(dims[1]))
return render_wiring_overlay_raster(
wiring,
hall_dims_m=hall_dims_m,
kinds=job.options.get("kinds"),
base_image=job.reference_image,
px_per_m=int(job.options.get("px_per_m", 40)),
)
def _render_live(self, job: RenderJob) -> GeneratedImage:
"""G1 승인 경로 — 실 Gemini 호출(NanoBananaClient 지연 생성)."""
if self._client is None:
try: # 패키지/스크립트 양쪽 임포트
from .client import NanoBananaClient # type: ignore
except ImportError:
from client import NanoBananaClient # type: ignore
self._client = NanoBananaClient(model=MODEL_NAME)
img = self._client.render_shot(
job.scene,
shot_preset=job.shot_preset,
reference_image=job.reference_image,
seed=job.seed,
layers=job.layers,
)
img.metadata.setdefault("render_path", "generative")
img.metadata["live"] = True
img.metadata["degraded"] = False
return img
def _render_mock(self, job: RenderJob) -> GeneratedImage:
"""기본 경로 — 목/degraded. 프롬프트·메타데이터는 실제와 동일 경로로 생성."""
# 프롬프트는 실제 빌더로 조립(구조 검증 목적) — 실패해도 잡은 진행
try:
prompt = build_booth_prompt(job.scene, shot_preset=job.shot_preset, layers=job.layers)
except Exception: # noqa: BLE001
prompt = f"[mock] {job.shot_preset} prompt build skipped"
meta = build_metadata(job.scene, "mock", job.shot_preset, job.seed)
meta["render_path"] = "generative"
meta["live"] = False
meta["degraded"] = True
preset = SHOT_PRESETS.get(job.shot_preset, {})
data, mime = _placeholder_png(
[
"KINTEX 나노바나나 — MOCK / DEGRADED",
f"shot: {job.shot_preset} {preset.get('label', '')}",
f"booth: {job.booth_id or '-'} event: {job.event_id or '-'}",
"G1 미승인/목 모드 — 실 Gemini 호출 없이 생성한 자리표시자",
]
)
return GeneratedImage(data=data, mime_type=mime, prompt_used=prompt, metadata=meta)
# ------------------------------------------------------------------
# 단건 처리 (테스트·스모크에서 직접 호출 가능 — Redis 불필요)
# ------------------------------------------------------------------
def process_job(self, job: RenderJob) -> RenderResult:
"""RenderJob 1건 처리: 렌더 → 스토리지 적재 → 완료 이벤트 발행."""
try:
image = self._render(job)
key = ObjectStore.key_for(job)
image_ref = self.store.save_image(image, key)
result = RenderResult(
job_id=job.job_id, status="DONE", image_ref=image_ref, meta=image.metadata
)
self.publisher.publish(self._event("renderjob.completed", job, result))
return result
except Exception as e: # noqa: BLE001 — 분류 후 친화 에러(비밀 미노출)
error = self._classify_error(e)
result = RenderResult(job_id=job.job_id, status="FAILED", error=error)
self.publisher.publish(self._event("renderjob.failed", job, result))
return result
@staticmethod
def _classify_error(e: Exception) -> dict:
"""예외 → {code,message}. 스택트레이스·키 미포함(PLANNING §6-5)."""
if isinstance(e, NanoBananaAuthError):
code = "AUTH"
elif isinstance(e, NanoBananaQuotaError):
code = "QUOTA"
elif isinstance(e, NanoBananaSafetyError):
code = "SAFETY"
elif isinstance(e, (ValueError, KeyError, TypeError)):
code = "BAD_REQUEST"
else:
code = "RENDER_ERROR"
return {"code": code, "message": str(e)[:300]}
@staticmethod
def _event(etype: str, job: RenderJob, result: RenderResult) -> dict:
return {
"type": etype,
"job_id": job.job_id,
"event_id": job.event_id,
"booth_id": job.booth_id,
"shot_preset": job.shot_preset,
"status": result.status,
"image_ref": result.image_ref,
"meta": result.meta,
"error": result.error,
"emitted_at": datetime.now(timezone.utc).isoformat(),
}
# ------------------------------------------------------------------
# 소비 루프 (지연 연결)
# ------------------------------------------------------------------
def _redis(self):
if self._r is None:
import redis # type: ignore # 지연 임포트
self._r = redis.from_url(self.redis_url)
return self._r
def stop(self) -> None:
self._stop = True
def run(self) -> None:
"""BLPOP 폴링 소비 루프. Redis 연결은 최초 진입 시점에 시도(지연)."""
mode = "LIVE(Gemini)" if is_live() else "MOCK/degraded"
print(f"[worker] start queue={self.queue_key} mode={mode} model={MODEL_NAME}")
r = self._redis()
while not self._stop:
item = r.blpop(self.queue_key, timeout=BLPOP_TIMEOUT_S)
if item is None:
continue # 폴링 타임아웃 — stop 플래그 재확인
_, raw = item
try:
payload = json.loads(raw)
job = RenderJob.from_dict(payload)
except Exception as e: # noqa: BLE001 — 파싱 실패 잡은 스킵(무한루프 방지)
print(f"[worker] drop malformed job: {type(e).__name__}", file=sys.stderr)
continue
self.process_job(job)
# ============================================================================
# 스모크 / CLI
# ============================================================================
def _smoke() -> int:
"""무네트워크 스모크: 목 모드로 생성형 잡 1건 + S6 잡 1건 처리, 사이드카 확인."""
import tempfile
tmp = Path(tempfile.mkdtemp(prefix="nanobanana_smoke_"))
worker = RenderWorker(store=ObjectStore(root=str(tmp)))
scene = {
"hall": {"id": "제1전시장 7홀", "dims_m": [126, 90], "ceiling_m": 12},
"booth": {"id": "A-102", "size_m": [6, 3], "type": "independent"},
"design": {"signage": {"text": "주식회사 가디아"}, "brand_color": "#0052A5"},
"lighting": {"mode": "night", "color_temp_k": 4000},
"wiring": {
"power": [{"from_trench": [1, 1], "to": [5, 2], "kw": 3}],
"network": [{"path": [[0, 0], [3, 1], [5, 2]]}],
},
"render_hints": {"style": "tech"},
}
ok = True
# (1) 생성형 샷 S2 (목 모드)
job1 = RenderJob(
job_id="smoke-s2", event_id="evt_smoke", booth_id="A-102", shot_preset="S2", scene=scene
)
r1 = worker.process_job(job1)
sidecar1 = tmp / (r1.image_ref["key"] + ".meta.json") if r1.image_ref else None
print(f"[smoke] S2 status={r1.status} live={r1.meta.get('live')} degraded={r1.meta.get('degraded')}")
if r1.status != "DONE" or not (sidecar1 and sidecar1.exists()):
ok = False
print("[smoke] FAIL: S2 결과/사이드카 누락")
# (2) S6 배선 오버레이(래스터 합성 — G1 무관)
job2 = RenderJob(
job_id="smoke-s6", event_id="evt_smoke", booth_id="A-102", shot_preset="S6", scene=scene
)
r2 = worker.process_job(job2)
sidecar2 = tmp / (r2.image_ref["key"] + ".meta.json") if r2.image_ref else None
rp = r2.meta.get("render_path")
print(f"[smoke] S6 status={r2.status} render_path={rp}")
# PIL 부재 환경에서는 S6 렌더가 실패할 수 있음(설계상 Pillow 필요) — 그 경우 관대 처리
if r2.status == "DONE":
if rp != "backend_raster_composite" or not (sidecar2 and sidecar2.exists()):
ok = False
print("[smoke] FAIL: S6 결과/사이드카/경로 불일치")
else:
print(f"[smoke] S6 skipped (환경 제약): {r2.error}")
# (3) ReRoom 사진 시안 R1 (목 모드) — 참조 이미지 경로만 있으면 진행(파일 없어도 목 경로).
job3 = RenderJob(
job_id="smoke-r1",
event_id="evt_smoke",
booth_id="A-102",
shot_preset="R1",
scene={},
mode="reroom",
style="tech",
instruction="파란색 브랜드 월과 로봇 데모 존 강조",
reference_image="mock/empty_booth.jpg",
)
r3 = worker.process_job(job3)
print(
f"[smoke] R1 status={r3.status} render_path={r3.meta.get('render_path')} "
f"style={r3.meta.get('style')} degraded={r3.meta.get('degraded')}"
)
if r3.status != "DONE" or r3.meta.get("render_path") != "reroom_image_to_image":
ok = False
print("[smoke] FAIL: R1 ReRoom 결과/경로 불일치")
print(f"[smoke] output dir: {tmp}")
print("[smoke] RESULT:", "PASS" if ok else "FAIL")
return 0 if ok else 1
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="나노바나나 RenderJob 워커")
parser.add_argument("--smoke", action="store_true", help="무네트워크 스모크(목 잡 처리)")
args = parser.parse_args(argv)
if args.smoke:
return _smoke()
RenderWorker().run()
return 0
if __name__ == "__main__":
raise SystemExit(main())