"""나노바나나(Gemini 이미지 생성) 연동 클라이언트. 킨텍스 AI 시스템의 모든 이미지 생성은 이 모듈을 통해서만 수행한다. 설계 기준: ReRoomAI 실증 패턴(docs/analysis/reroomai-source.md) + PLANNING §6 나노바나나 시각화 파이프라인. 핵심 패턴 (ReRoomAI 차용): 1. 호출 스택 — google-genai SDK로 gemini-3.1-flash-image-preview(나노바나나 2)를 image-to-image 편집 호출. 참조 이미지(inlineData: mimeType+base64) + 지시문(text)을 parts 배열로 전달해 부스 골격을 보존하고 스타일만 사실화한다. 2. 프롬프트 = 구조화 사전 조립 + "보존 잠금 / 교체 지정 / 사진 품질" 3단 분리. 3. 프로덕션 방어 — 입력 크기 가드, SAFETY 처리, 에러 분기, 성공 시에만 쿼터 차감. 보안: - API 키는 환경변수 GEMINI_API_KEY 에서만 로드한다. - 키/네트워크 없이도 import·구조가 성립하도록 방어적으로 작성(SDK 지연 임포트). - 실제 Gemini 호출은 소유자 승인 대기 사항(reroomai-source.md §8) — 승인 전 실호출 강제 금지. 사용 예 (승인 후): from tools.nanobanana.client import NanoBananaClient client = NanoBananaClient() img = client.render_shot(scene, shot_preset="S1", reference_image="empty_booth.jpg") img.save("output/visualizations/booth_S1.png") # 사이드카 .meta.json 동시 기록 """ from __future__ import annotations import hashlib import json import mimetypes import os from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Optional # ---------------------------------------------------------------------------- # 모델 / 경로 상수 # ---------------------------------------------------------------------------- #: 기본 모델 = 나노바나나 2 (ReRoomAI 검증 모델). 환경변수로 오버라이드 가능. MODEL_NAME = os.environ.get("NANOBANANA_MODEL", "gemini-3.1-flash-image-preview") PROMPTS_DIR = Path(__file__).parent / "prompts" #: 클라이언트 Canvas 전처리와 정합(ReRoomAI §4-D) — 긴 쪽 1024px / JPEG 품질 0.85 MAX_UPLOAD_SIDE = 1024 UPLOAD_JPEG_QUALITY = 85 #: 입력 이미지 상한 (ReRoomAI route.ts content-length 8MB 가드와 정합) MAX_IMAGE_BYTES = 8 * 1024 * 1024 # ---------------------------------------------------------------------------- # 예외 계층 (프로덕션 에러 분기용) # ---------------------------------------------------------------------------- class NanoBananaError(RuntimeError): """나노바나나 연동 일반 오류.""" class NanoBananaAuthError(NanoBananaError): """API 키 무효/누락.""" class NanoBananaQuotaError(NanoBananaError): """쿼터 초과 / 429 RESOURCE_EXHAUSTED.""" class NanoBananaSafetyError(NanoBananaError): """콘텐츠 안전 정책 차단(finishReason == SAFETY).""" # ============================================================================ # 구조화 프롬프트 사전 (ReRoomAI lib/constants.ts 패턴의 부스 도메인 치환) # 각 항목 { label(한글), prompt(영문) [, swatch] } — UI 라벨 ↔ 서버 프롬프트 단일 출처. # ============================================================================ #: 부스 타입 — PLANNING §6-2 booth.type 값과 매핑 BOOTH_TYPES: dict[str, dict[str, str]] = { "independent": { "label": "독립부스(목공)", "prompt": "an independent custom-built wooden booth with solid walls and a fascia band", }, "assembly": { "label": "조립부스(시스템)", "prompt": "a modular system booth built from octanorm aluminium frames and infill panels", }, "corner": { "label": "코너부스", "prompt": "a corner booth open on two adjacent aisle sides", }, "island": { "label": "아일랜드부스", "prompt": "an island booth open on all four sides with a central tower feature", }, } #: 부스 스타일 — render_hints.style 세분화. swatch 는 색 참조용(주 색상은 design 에서 별도 주입). BOOTH_STYLES: dict[str, dict[str, Any]] = { "luxury": { "label": "럭셔리", "swatch": ["#1a1a1a", "#c9a24b", "#f4f1ea"], "prompt": "premium luxury tradeshow style: warm gold accents, dark stone-look panels, " "backlit brand wall, layered warm accent lighting, polished finishes", }, "tech": { "label": "테크", "swatch": ["#0a0e27", "#00d4ff", "#e6f7ff"], "prompt": "high-tech style: clean matte panels, cool LED edge lighting, large digital " "display walls, minimal seams, dark base with cyan light accents", }, "eco": { "label": "친환경", "swatch": ["#2f4f3e", "#a8c686", "#f2efe6"], "prompt": "eco / natural style: light wood, plants and greenery, recycled material " "textures, soft daylight-balanced lighting, matte earth tones", }, "minimal": { "label": "미니멀", "swatch": ["#ffffff", "#d8d8d8", "#333333"], "prompt": "minimal style: white matte walls, clean lines, single accent color, " "even soft lighting, uncluttered product plinths", }, "photoreal_tradeshow": { # PLANNING §6-2 render_hints.style 기본값 폴백 "label": "표준 사실화", "swatch": ["#ffffff", "#0052A5", "#333333"], "prompt": "clean professional tradeshow style: balanced neutral finishes with the brand " "accent color, even exhibition lighting, tidy product presentation", }, } #: 집기/배선 레이어별 프롬프트 조각 — 레이어 on/off 로 S2(조명)·S4(집기) 등 변형. FIXTURE_LAYERS: dict[str, dict[str, str]] = { "furniture": { "label": "집기", "prompt": "reception desk, product display shelves, consultation table with stools, " "brand banners and printed fascia signage, floor carpet inside the booth", }, "lighting": { "label": "조명", "prompt": "ceiling spotlights and track lights aimed at products, backlit logo, " "linear accent lighting along shelves", }, "electrical": { "label": "전기", "prompt": "power outlets and floor-box connections at fixtures, cable management hidden " "in wall cavities and under the raised carpet edge", }, "network": { "label": "네트워크", "prompt": "network access point and LAN drops for the demo stations, discreetly mounted", }, } #: 표준 샷 세트 S1~S7 (PLANNING §6-3). camera=구도 문구, mode=조명 모드 override, #: mode_hint=낮/밤 분위기, generative=생성형 여부(S6 는 백엔드 래스터 합성 경로). SHOT_PRESETS: dict[str, dict[str, Any]] = { "S1": { "label": "부스 정면 주간", "camera": "eye-level front view from the visitor aisle, full-frame 24mm lens at 1.6m height", "mode": "day", "generative": True, }, "S2": { "label": "야간/점등 뷰", "camera": "same front framing as S1, evening ambience emphasising the booth's own lighting", "mode": "night", "generative": True, }, "S3": { "label": "통로 뷰", "camera": "three-quarter view from the adjacent aisle, neighbouring booths partly visible", "mode": "day", "generative": True, }, "S4": { "label": "부스 내부", "camera": "interior view from the consultation zone looking out, showing fixtures and layout", "mode": "day", "generative": True, }, "S5": { "label": "Before/After", "camera": "eye-level front view identical to S1 for before/after pairing", "mode": "day", "generative": True, }, "S6": { "label": "배선 오버레이", "camera": "top-down floor plan overlay", "mode": "day", "generative": False, # ★ 백엔드 래스터 합성 경로 (render_wiring_overlay_raster) }, "S7": { "label": "홀 전경(조감)", "camera": "high-angle isometric overview of the exhibition hall floor with this booth in context", "mode": "day", "generative": True, }, "R1": { # ReRoomAI 방식 사진 시안 — 참조 사진 골격 보존 image-to-image (소유자 지시 13.2) "label": "사진 시안(ReRoom)", "camera": "keep the exact camera perspective and framing of the reference photo", "mode": "day", "generative": True, "reroom": True, }, } #: S6 배선 색상 규약 (design.md §1-2 / PLANNING §6-3과 동일) WIRING_COLORS = {"power": "red", "network": "blue", "plumbing": "green"} # ============================================================================ # ReRoomAI 방식 사진 시안 (image-to-image) — 소유자 지시 13.2 # 빈 부스/공간 사진 → 골격·구도 보존 + 표면 요소만 교체한 "시공 후" 사진. # 프롬프트는 ReRoomAI(§4) 4단 구조를 그대로 따르되, 자연어 지시는 "교체" 파트에만 삽입한다. # ============================================================================ #: ReRoom 스타일 사전 — 단일 출처는 prompts/reroom_styles.json, 부재 시 이 in-code 폴백. # 각 항목 { label(한글), prompt(영문 교체 지시), swatch(색3종) }. REROOM_STYLES_FALLBACK: dict[str, dict[str, Any]] = { "modern": { "label": "모던", "swatch": ["#2b2b2e", "#8a8f98", "#f2f2f0"], "prompt": "sleek modern exhibition style: clean lines, neutral charcoal-and-greige palette, " "low-profile furniture, matte finishes, crisp statement lighting", }, "minimal": { "label": "미니멀", "swatch": ["#ffffff", "#d8d8d8", "#333333"], "prompt": "minimal style: white matte walls, uncluttered product plinths, a single accent " "color, even soft lighting, generous negative space", }, "tech": { "label": "테크", "swatch": ["#0a0e27", "#00d4ff", "#e6f7ff"], "prompt": "high-tech style: dark matte panels, cool cyan LED edge lighting, large digital " "display walls, seamless joints, futuristic product pedestals", }, "luxury": { "label": "럭셔리", "swatch": ["#1a1a1a", "#c9a24b", "#f4f1ea"], "prompt": "premium luxury style: warm gold accents, dark stone-look panels, a backlit brand " "wall, layered warm accent lighting, polished reflective finishes", }, "korean_traditional": { "label": "한국 전통", "swatch": ["#8a5a2b", "#c8b18a", "#2f3b30"], "prompt": "modern Korean traditional (hanok-inspired) style: warm wood lattice (salpi) " "screens, hanji paper textures, dancheong-inspired accent colors used sparingly, " "soft warm lighting, natural materials", }, "eco": { "label": "친환경", "swatch": ["#2f4f3e", "#a8c686", "#f2efe6"], "prompt": "eco / natural style: light wood, live plants and greenery, recycled-material " "textures, daylight-balanced lighting, calm matte earth tones", }, } def _load_reroom_styles() -> dict[str, dict[str, Any]]: """prompts/reroom_styles.json 을 단일 출처로 로드, 부재/파싱 실패 시 in-code 폴백.""" f = PROMPTS_DIR / "reroom_styles.json" try: if f.exists(): data = json.loads(f.read_text(encoding="utf-8")) if isinstance(data, dict) and data: return data except Exception: # noqa: BLE001 — 파일 문제로 모듈이 깨지지 않게(방어) pass return REROOM_STYLES_FALLBACK #: 로드된 ReRoom 스타일(파일 우선). UI 라벨 ↔ 서버 프롬프트 단일 출처. REROOM_STYLES: dict[str, dict[str, Any]] = _load_reroom_styles() def _reroom_preserve_lock() -> str: """보존 잠금부(고정) — 참조 사진의 건축 골격·카메라 구도를 그대로 유지(ReRoomAI 보존 패턴).""" return ( "Keep the architectural shell exactly the same as the reference photo: the walls, floor, " "ceiling, structural columns, ceiling trusses, aisle openings and the camera perspective " "must all stay identical. Do not move, resize or re-frame the structure." ) def build_reroom_prompt( style_id: str = "modern", instruction: Optional[str] = None, mode: str = "day", ) -> str: """ReRoomAI 4단 구조 지시문 — ① 대상+스타일 → ② 보존 잠금 → ③ 교체(+자연어 지시) → ④ 사진 품질. style_id: REROOM_STYLES 키. 미지정/미존재 시 modern 폴백. instruction: 사용자 자연어 지시(선택). ★교체 파트에만 삽입 — 보존 지시는 고정. """ style = REROOM_STYLES.get(style_id) or REROOM_STYLES.get("modern") or REROOM_STYLES_FALLBACK["modern"] # ① 대상 + 스타일 header = ( "Photorealistic professional photograph of this exhibition booth space at the KINTEX " "convention center in South Korea, shown as if construction is complete. " f"Redesign it in this style: {style['prompt']}." ) # ③ 교체 지정 — 표면 요소만. 자연어 지시는 여기(교체 파트)에만 붙는다. replace_parts = [ "Replace only the surfaces, furnishings, lighting, signage graphics, carpet and decor to " "match the target style. Do not add real company logos or human faces." ] if instruction and instruction.strip(): replace_parts.append(f"Additional client direction (apply to the styling only): {instruction.strip()}") replace_spec = " ".join(replace_parts) return "\n\n".join( [header, _reroom_preserve_lock(), replace_spec, _photo_quality(mode)] ) # ============================================================================ # 프롬프트 빌더 — "보존 잠금 / 교체 지정 / 사진 품질" 3단 조립 # ============================================================================ def _preserve_lock(scene: dict) -> str: """보존 잠금부: 부스 골격·구도를 그대로 유지하라는 명시적 지시(ReRoomAI 보존 패턴).""" return ( "Keep the booth structure exactly the same as the reference: outer footprint dimensions, " "structural columns, the floor trench grid, the ceiling truss layout, the aisle orientation " "and the camera angle must all stay identical. Do not move walls or change proportions." ) def _replace_spec(scene: dict, layers: list[str]) -> str: """교체 지정부: design/lighting/wiring 스키마를 자연어로 직렬화하고 교체 대상을 명시.""" design = scene.get("design", {}) or {} lighting = scene.get("lighting", {}) or {} parts: list[str] = ["Build and place the following into the booth:"] # 활성 레이어별 조각 조립 for key in layers: frag = FIXTURE_LAYERS.get(key) if frag: parts.append(f"- {frag['prompt']}.") # 간판 문구 (한글, 정확 렌더 지시 — 실패 시 후처리 합성은 SKILL.md 참조) signage = (design.get("signage") or {}).get("text") if signage: parts.append(f'- Fascia signage text, render exactly in Korean: "{signage}".') # 자재/마감 materials = design.get("materials") or [] if materials: mat_str = ", ".join( m.get("finish", str(m)) if isinstance(m, dict) else str(m) for m in materials ) parts.append(f"- Materials and finishes: {mat_str}.") # 존 구성 zones = design.get("zones") or [] if zones: zone_str = ", ".join(z.get("type", str(z)) if isinstance(z, dict) else str(z) for z in zones) parts.append(f"- Functional zones: {zone_str}.") # 조명 색온도 color_temp = lighting.get("color_temp_k") if color_temp: parts.append(f"- Overall lighting color temperature about {color_temp}K.") return "\n".join(parts) def _photo_quality(mode: str) -> str: """사진 품질부.""" tod = "evening / after-hours" if mode == "night" else "bright daytime hall" return ( f"Photorealistic tradeshow photography, {tod} lighting, natural exhibition ambient light " "with visible ceiling trusses, sharp focus, high detail. No people, no real brand logos " "other than the specified signage text. It must look like a real construction-completion " "documentation photo, not a 3D render." ) def _scene_view(scene: dict) -> tuple[dict, dict, dict]: """scene 에서 hall/booth/design 을 관대하게 추출(중첩/평면 모두 허용).""" inner = scene.get("scene", scene) hall = inner.get("hall", {}) or {} booth = inner.get("booth", {}) or {} design = inner.get("design", {}) or {} return hall, booth, design def build_booth_prompt( scene: dict, shot_preset: str = "S1", layers: Optional[list[str]] = None, ) -> str: """PLANNING §6-2 scene 스키마 → 최종 지시문. scene: hall: { id, dims_m, ceiling_m, floor } booth: { id, polygon, size_m, height_m, type } design: { walls, signage:{text,height_m}, zones, materials } lighting: { fixtures, mode } wiring: { power, network } shot / render_hints (top-level 또는 scene 내부 모두 허용) 반환: "① 대상+스타일 → ② 보존 잠금 → ③ 교체 지정 → ④ 사진 품질" 4단 지시문. """ inner = scene.get("scene", scene) hall = inner.get("hall", {}) or {} booth = inner.get("booth", {}) or {} design = inner.get("design", {}) or {} lighting = inner.get("lighting", {}) or {} render_hints = scene.get("render_hints") or inner.get("render_hints") or {} preset = SHOT_PRESETS.get(shot_preset, SHOT_PRESETS["S1"]) mode = lighting.get("mode") or preset.get("mode", "day") # 활성 레이어 기본값: 샷별. S2 는 조명 강조, S4 는 집기 강조. if layers is None: if shot_preset == "S2": layers = ["lighting", "furniture"] elif shot_preset == "S4": layers = ["furniture", "electrical", "network"] else: layers = ["furniture", "lighting", "electrical", "network"] # 부스 타입 / 스타일 사전 조회 btype = BOOTH_TYPES.get(booth.get("type", ""), BOOTH_TYPES["independent"]) style_id = render_hints.get("style", "photoreal_tradeshow") style = BOOTH_STYLES.get(style_id, BOOTH_STYLES["photoreal_tradeshow"]) size = booth.get("size_m") or [3, 3] w, d = (size[0], size[1]) if len(size) >= 2 else (3, 3) hall_name = hall.get("id") or hall.get("name") or "a KINTEX exhibition hall" brand = (design.get("brand_color")) or "#0052A5" # ① 대상 + 스타일 header = ( f"Photorealistic professional photograph of a completed {btype['prompt']} " f"at {hall_name}, KINTEX convention center in South Korea, " f"{w}m wide x {d}m deep, immediately after construction, before visitors arrive. " f"Primary brand color {brand}. Design style: {style['prompt']}. " f"Camera: {preset['camera']}." ) return "\n\n".join( [ header, _preserve_lock(inner), _replace_spec(inner, layers), _photo_quality(mode), ] ) # ============================================================================ # 이미지 전처리 유틸 (ReRoomAI Canvas 전처리의 서버측 대응) # ============================================================================ def downscale_for_upload( source: str | Path | bytes, max_side: int = MAX_UPLOAD_SIDE, jpeg_quality: int = UPLOAD_JPEG_QUALITY, ) -> tuple[bytes, str]: """긴 쪽 max_side px 다운스케일 + JPEG 품질 압축 → (bytes, mime_type). PIL 미설치 시 원본을 그대로 반환(mime 추정). ReRoomAI Studio.handleImageFile 등가. """ if isinstance(source, (str, Path)): raw = Path(source).read_bytes() guessed = mimetypes.guess_type(str(source))[0] or "image/png" else: raw = source guessed = "image/png" if len(raw) > MAX_IMAGE_BYTES: raise NanoBananaError( f"입력 이미지가 상한({MAX_IMAGE_BYTES // (1024 * 1024)}MB)을 초과합니다." ) try: import io from PIL import Image # type: ignore except ImportError: return raw, guessed # PIL 없으면 원본 유지 with Image.open(io.BytesIO(raw)) as im: im = im.convert("RGB") long_side = max(im.size) if long_side > max_side: scale = max_side / long_side new_size = (round(im.size[0] * scale), round(im.size[1] * scale)) im = im.resize(new_size, Image.LANCZOS) out = io.BytesIO() im.save(out, format="JPEG", quality=jpeg_quality) return out.getvalue(), "image/jpeg" # ============================================================================ # 메타데이터 (B-03: 생성일·스키마 해시·모델 버전 결정적 임베드) # ============================================================================ def _schema_hash(scene: dict) -> str: """scene 스키마의 결정적 SHA-256 해시(정렬 직렬화). 캐시 키·감사 추적용.""" canon = json.dumps(scene, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canon.encode("utf-8")).hexdigest() def build_metadata(scene: dict, model: str, shot_preset: str, seed: Optional[int]) -> dict: return { "generated_at": datetime.now(timezone.utc).isoformat(), "schema_hash": _schema_hash(scene), "model_version": model, "shot_preset": shot_preset, "seed": seed, "watermark_required": True, "watermark_text": "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음", "notice": "계약·심사 서류 사용 금지(도면만 유효)", # PLANNING §6-5 } @dataclass class GeneratedImage: data: bytes mime_type: str prompt_used: str metadata: dict = field(default_factory=dict) def save(self, path: str | Path) -> Path: """이미지 저장 + 결정적 메타데이터 임베드. - 사이드카 JSON(`.meta.json`)을 항상 기록(결정적). - PNG 이면 tEXt 청크에 메타데이터를 추가 임베드(PIL 있을 때). """ p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) wrote_embedded = False if p.suffix.lower() == ".png": try: import io from PIL import Image # type: ignore from PIL.PngImagePlugin import PngInfo # type: ignore info = PngInfo() for k, v in self.metadata.items(): info.add_text(f"kintex:{k}", json.dumps(v, ensure_ascii=False)) with Image.open(io.BytesIO(self.data)) as im: im.save(p, format="PNG", pnginfo=info) wrote_embedded = True except Exception: wrote_embedded = False if not wrote_embedded: p.write_bytes(self.data) # 사이드카 JSON — 항상, 결정적 sidecar = p.with_suffix(p.suffix + ".meta.json") sidecar.write_text( json.dumps(self.metadata, ensure_ascii=False, indent=2), encoding="utf-8" ) return p def _load_template(name: str) -> str: f = PROMPTS_DIR / f"{name}.txt" if not f.exists(): raise NanoBananaError(f"프롬프트 템플릿 없음: {f}") return f.read_text(encoding="utf-8") # ============================================================================ # 클라이언트 # ============================================================================ class NanoBananaClient: """Gemini 이미지 생성 API 래퍼. google-genai SDK, image-to-image 편집. seed 지원 여부는 런타임에 자동 판별(B-12). 미지원 시 참조체인 기반 일관성으로 폴백. usage_recorder: 성공 시에만 호출되는 훅(쿼터/카운트 차감) — 실패는 소모 안 함. """ WIRING_COLORS = WIRING_COLORS def __init__( self, api_key: Optional[str] = None, model: str = MODEL_NAME, usage_recorder: Optional[Callable[[dict], None]] = None, ): # 보안: 키는 env GEMINI_API_KEY 에서만 로드. 인자 주입은 테스트/운영 편의용. self.api_key = api_key or os.environ.get("GEMINI_API_KEY") if not self.api_key: raise NanoBananaAuthError( "GEMINI_API_KEY 환경변수가 필요합니다. " "https://aistudio.google.com 에서 발급 후 설정하세요." ) self.model = model self.usage_recorder = usage_recorder self._seed_supported: Optional[bool] = None # None=미확인, True/False=판별됨 # 지연 임포트: SDK 미설치 환경에서도 모듈 로드는 가능하게(승인 전 방어) try: from google import genai # type: ignore except ImportError as e: raise NanoBananaError("pip install google-genai 필요") from e self._client = genai.Client(api_key=self.api_key) # ------------------------------------------------------------------ # 저수준 생성 # ------------------------------------------------------------------ def generate( self, prompt: str, reference_image: Optional[str | Path | bytes] = None, seed: Optional[int] = None, metadata: Optional[dict] = None, ) -> GeneratedImage: """프롬프트(+선택 참조 이미지) → 이미지 1장. image-to-image 편집. 참조 이미지는 업로드 전 긴 쪽 1024px 로 다운스케일된다(ReRoomAI 전처리). """ from google.genai import types # type: ignore contents: list = [] if reference_image is not None: img_bytes, mime = downscale_for_upload(reference_image) contents.append(types.Part.from_bytes(data=img_bytes, mime_type=mime)) contents.append(prompt) resp = self._call_model(contents, seed) result = self._extract_image(resp, prompt, metadata or {}) # 성공 시에만 사용량 차감(ReRoomAI: 실패는 횟수 소모 안 함) if self.usage_recorder is not None: try: self.usage_recorder(result.metadata) except Exception: pass return result def _build_config(self, seed: Optional[int]): """seed 지원 시 GenerateContentConfig(seed=...) 구성. 미지원이면 None.""" if seed is None or self._seed_supported is False: return None try: from google.genai import types # type: ignore return types.GenerateContentConfig(seed=seed) except Exception: self._seed_supported = False return None def _call_model(self, contents: list, seed: Optional[int]): """SDK 호출 + 에러 분기(키/쿼터/SAFETY). seed 폴백 포함(B-12).""" cfg = self._build_config(seed) try: if cfg is not None: resp = self._client.models.generate_content( model=self.model, contents=contents, config=cfg ) self._seed_supported = True return resp return self._client.models.generate_content( model=self.model, contents=contents ) except TypeError: # seed/config 파라미터 미지원 SDK → 참조체인 일관성으로 폴백 self._seed_supported = False return self._client.models.generate_content( model=self.model, contents=contents ) except Exception as e: # noqa: BLE001 — 메시지 기반 분류 self._raise_classified(e) @staticmethod def _raise_classified(e: Exception): msg = str(e) upper = msg.upper() if "API_KEY_INVALID" in upper or "API KEY NOT VALID" in upper or "UNAUTHENT" in upper: raise NanoBananaAuthError( "API 키가 유효하지 않습니다. GEMINI_API_KEY 를 확인하세요." ) from e if "RESOURCE_EXHAUSTED" in upper or "QUOTA" in upper or "429" in msg: raise NanoBananaQuotaError( "요청 한도(쿼터)를 초과했습니다. 잠시 후 다시 시도하세요." ) from e if "SAFETY" in upper or "BLOCKED" in upper: raise NanoBananaSafetyError( "안전 정책에 의해 생성이 차단되었습니다. 지시문/참조 이미지를 조정하세요." ) from e raise NanoBananaError(f"이미지 생성 실패: {msg}") from e def _extract_image(self, resp, prompt: str, metadata: dict) -> GeneratedImage: candidates = getattr(resp, "candidates", None) or [] if not candidates: raise NanoBananaError("응답에 candidate가 없습니다: " + str(resp)) cand = candidates[0] # finishReason == SAFETY 처리(ReRoomAI route.ts) fr = getattr(cand, "finish_reason", None) fr_name = getattr(fr, "name", None) or (str(fr) if fr is not None else "") if "SAFETY" in fr_name.upper(): raise NanoBananaSafetyError( "안전 정책에 의해 생성이 차단되었습니다(finishReason=SAFETY)." ) content = getattr(cand, "content", None) parts = getattr(content, "parts", None) or [] for part in parts: inline = getattr(part, "inline_data", None) if inline is not None and getattr(inline, "data", None): return GeneratedImage( data=inline.data, mime_type=getattr(inline, "mime_type", "image/png"), prompt_used=prompt, metadata=metadata, ) raise NanoBananaError("이미지가 반환되지 않았습니다: " + str(resp)) # ------------------------------------------------------------------ # 고수준: 샷 렌더 (프롬프트 빌더 경유) # ------------------------------------------------------------------ def render_shot( self, scene: dict, shot_preset: str = "S1", reference_image: Optional[str | Path | bytes] = None, seed: Optional[int] = None, layers: Optional[list[str]] = None, ) -> GeneratedImage: """PLANNING §6-2 scene → 표준 샷(S1~S7) 사실화 사진. S6(배선 오버레이)는 생성형이 아니라 백엔드 래스터 합성 경로다 — render_wiring_overlay_raster() 를 사용하라(여기서 호출 시 명시적 에러). """ preset = SHOT_PRESETS.get(shot_preset) if preset is None: raise NanoBananaError(f"알 수 없는 샷 프리셋: {shot_preset} (S1~S7)") if not preset.get("generative", True): raise NanoBananaError( f"{shot_preset}({preset['label']})는 생성형이 아니라 " "백엔드 래스터 합성 경로입니다 → render_wiring_overlay_raster() 사용." ) prompt = build_booth_prompt(scene, shot_preset=shot_preset, layers=layers) metadata = build_metadata(scene, self.model, shot_preset, seed) return self.generate( prompt, reference_image=reference_image, seed=seed, metadata=metadata ) # 하위호환: 평면 booth_spec → scene 어댑트 후 render_shot 위임 -------- def generate_booth_photo( self, booth_spec: dict, view: str = "front", time_of_day: str = "day", reference_image: Optional[str | Path | bytes] = None, seed: Optional[int] = None, ) -> GeneratedImage: """[하위호환] 평면 booth_spec dict → 시공 후 사진. 내부적으로 §6-2 scene 으로 변환 후 render_shot 위임. view: front|aisle|interior|aerial, time_of_day: day|night. """ view_to_shot = { ("front", "day"): "S1", ("front", "night"): "S2", ("aisle", "day"): "S3", ("interior", "day"): "S4", ("aerial", "day"): "S7", } shot = view_to_shot.get((view, time_of_day)) if shot is None: shot = {"front": "S1", "aisle": "S3", "interior": "S4", "aerial": "S7"}.get( view, "S1" ) if time_of_day == "night": shot = "S2" size = booth_spec.get("size_m", (3, 3)) scene = { "hall": {"id": booth_spec.get("hall", "KINTEX exhibition hall")}, "booth": { "size_m": list(size), "type": _map_legacy_booth_type(booth_spec.get("booth_type", "")), }, "design": { "signage": {"text": booth_spec.get("signage", "")}, "brand_color": booth_spec.get("brand_color", "#0052A5"), "materials": [{"finish": m} for m in booth_spec.get("materials", [])], }, "lighting": { "mode": time_of_day, "color_temp_k": booth_spec.get("lighting", {}).get("color_temp_k", 4000), }, "render_hints": {"style": booth_spec.get("style", "photoreal_tradeshow")}, } return self.render_shot( scene, shot_preset=shot, reference_image=reference_image, seed=seed ) # ------------------------------------------------------------------ # ReRoomAI 방식 사진 시안 (image-to-image) — 소유자 지시 13.2 # ------------------------------------------------------------------ def render_reroom( self, reference_image: str | Path | bytes, style: str = "modern", instruction: Optional[str] = None, seed: Optional[int] = None, shot_preset: str = "R1", ) -> GeneratedImage: """빈 부스/공간 참조 사진 → 골격·구도 보존 + 표면 교체 "시공 후" 사진. ReRoomAI 패턴: 참조 이미지 필수(image-to-image). 프롬프트는 보존/교체 명시 분리. """ if reference_image is None: raise NanoBananaError("사진 시안(ReRoom)에는 참조 이미지가 필요합니다.") prompt = build_reroom_prompt(style, instruction) scene = {"reroom": True, "style": style} metadata = build_metadata(scene, self.model, shot_preset, seed) metadata["reroom"] = True metadata["style"] = style return self.generate( prompt, reference_image=reference_image, seed=seed, metadata=metadata ) # ------------------------------------------------------------------ # S6 생성형 오버레이 (발표/설명용 보조 — 시공 검증 아님) # ------------------------------------------------------------------ def generate_wiring_overlay( self, layout_json: dict, kind: str = "network" ) -> GeneratedImage: """[보조] 배치도 + 배선 계획 → 생성형 오버레이(발표/설명용). ★ 시공 검증용 S6 1차 산출물은 좌표 정합이 보장되는 백엔드 래스터 합성 (render_wiring_overlay_raster)이다. 이 생성형 이미지는 시공 검증에 쓰지 말 것. """ if kind not in self.WIRING_COLORS: raise NanoBananaError(f"kind는 {list(self.WIRING_COLORS)} 중 하나: {kind}") template = _load_template("wiring_overlay") prompt = template.format( kind=kind, color=self.WIRING_COLORS[kind], layout=json.dumps(layout_json, ensure_ascii=False), ) return self.generate(prompt) def _map_legacy_booth_type(s: str) -> str: s = (s or "").lower() if "island" in s or "아일랜드" in s: return "island" if "corner" in s or "코너" in s: return "corner" if "system" in s or "assembly" in s or "조립" in s or "시스템" in s: return "assembly" return "independent" # ============================================================================ # B-02: S6 배선 오버레이 = 백엔드 래스터 합성 렌더러 (생성형 아님, PIL 로컬) # ============================================================================ def render_wiring_overlay_raster( wiring: dict, hall_dims_m: tuple[float, float], kinds: Optional[list[str]] = None, base_image: Optional[str | Path] = None, px_per_m: int = 40, out_size: Optional[tuple[int, int]] = None, ) -> GeneratedImage: """도면 위 배선 경로를 좌표 정합으로 래스터 합성(S6 시공 검증용 1차 산출물). 생성형(나노바나나)이 아니라 로컬 PIL 합성이다 — 좌표가 도면과 정확히 일치한다. wiring: PLANNING §6-2 scene.wiring { power: [{from_trench:[x,y], to:[x,y], kw}], network:[{path:[[x,y],...]}], plumbing:[...] } hall_dims_m: (width_m, depth_m) — 좌표계 기준(미터). kinds: 렌더할 레이어(기본: power/network/plumbing 중 존재하는 것 전부). base_image: 있으면 배경 도면 위에 합성, 없으면 흰 배경. 색상 규약: power=적, network=청, plumbing=녹 (WIRING_COLORS). """ try: import io from PIL import Image, ImageDraw # type: ignore except ImportError as e: raise NanoBananaError("배선 래스터 합성에는 pip install Pillow 필요") from e w_m, d_m = hall_dims_m if out_size is None: out_size = (int(w_m * px_per_m), int(d_m * px_per_m)) W, H = out_size if base_image is not None: with Image.open(base_image) as bg: canvas = bg.convert("RGB").resize((W, H)) else: canvas = Image.new("RGB", (W, H), "white") draw = ImageDraw.Draw(canvas) def to_px(pt) -> tuple[int, int]: return (int(pt[0] * px_per_m), int(pt[1] * px_per_m)) if kinds is None: kinds = [k for k in ("power", "network", "plumbing") if wiring.get(k)] rendered_counts: dict[str, int] = {} for kind in kinds: color = WIRING_COLORS.get(kind, "black") runs = wiring.get(kind) or [] n = 0 for run in runs: # 경로 형태 1: {path: [[x,y],...]} path = run.get("path") if isinstance(run, dict) else None # 경로 형태 2: {from_trench:[x,y], to:[x,y]} if not path and isinstance(run, dict): a = run.get("from_trench") or run.get("from") b = run.get("to") if a and b: path = [a, b] if not path: continue pts = [to_px(p) for p in path] if len(pts) >= 2: draw.line(pts, fill=color, width=3, joint="curve") # 트렌치 포트 마커 draw.ellipse( [pts[0][0] - 5, pts[0][1] - 5, pts[0][0] + 5, pts[0][1] + 5], outline=color, width=2, ) n += 1 rendered_counts[kind] = n # 범례 박스 lx, ly = 10, 10 for kind in kinds: color = WIRING_COLORS.get(kind, "black") draw.line([(lx, ly + 6), (lx + 24, ly + 6)], fill=color, width=3) draw.text((lx + 30, ly), f"{kind}", fill="black") ly += 18 buf = io.BytesIO() canvas.save(buf, format="PNG") return GeneratedImage( data=buf.getvalue(), mime_type="image/png", prompt_used="[raster-composite] wiring overlay (non-generative)", metadata={ "generated_at": datetime.now(timezone.utc).isoformat(), "render_path": "backend_raster_composite", "kinds": kinds, "rendered_counts": rendered_counts, "px_per_m": px_per_m, "watermark_required": True, "watermark_text": "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음", }, )