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>
This commit is contained in:
zio 2026-07-14 00:25:23 +09:00
parent ba34abaafe
commit 0f91dcf4ae
16 changed files with 1174 additions and 24 deletions

View File

@ -1,5 +1,6 @@
package com.zioinfo.kintex.module.m5; package com.zioinfo.kintex.module.m5;
import com.zioinfo.kintex.module.m5.dto.ReRoomJobRequest;
import com.zioinfo.kintex.module.m5.dto.RenderJobDto; import com.zioinfo.kintex.module.m5.dto.RenderJobDto;
import com.zioinfo.kintex.module.m5.dto.RenderJobRequest; import com.zioinfo.kintex.module.m5.dto.RenderJobRequest;
import com.zioinfo.kintex.module.m5.dto.WorkerCallbackRequest; import com.zioinfo.kintex.module.m5.dto.WorkerCallbackRequest;
@ -12,6 +13,13 @@ public interface RenderJobService {
/** RenderJob 발행 — 쿼터 확인 후 Redis 큐에 적재. 상태 QUEUED로 반환. */ /** RenderJob 발행 — 쿼터 확인 후 Redis 큐에 적재. 상태 QUEUED로 반환. */
RenderJobDto publish(String eventId, String boothId, RenderJobRequest request); RenderJobDto publish(String eventId, String boothId, RenderJobRequest request);
/**
* ReRoomAI 방식 사진 시안(image-to-image) 발행 소유자 지시 13.2.
* 발행/쿼터/상태/콜백/푸시 경로는 {@link #publish} 동일하게 재사용하되,
* 워커에 참조 이미지 로컬경로·스타일·자연어 지시·mode=reroom 함께 적재한다.
*/
RenderJobDto publishReRoom(String eventId, String boothId, ReRoomJobRequest request);
/** 잡 상태 조회. */ /** 잡 상태 조회. */
RenderJobDto getStatus(String eventId, String jobId); RenderJobDto getStatus(String eventId, String jobId);

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.module.m5.dto.ReRoomJobRequest;
import com.zioinfo.kintex.module.m5.dto.RenderJobDto; import com.zioinfo.kintex.module.m5.dto.RenderJobDto;
import com.zioinfo.kintex.module.m5.dto.RenderJobRequest; import com.zioinfo.kintex.module.m5.dto.RenderJobRequest;
import com.zioinfo.kintex.module.m5.dto.WorkerCallbackRequest; import com.zioinfo.kintex.module.m5.dto.WorkerCallbackRequest;
@ -51,6 +52,33 @@ public class RenderJobServiceImpl implements RenderJobService {
@Override @Override
public RenderJobDto publish(String eventId, String boothId, RenderJobRequest request) { public RenderJobDto publish(String eventId, String boothId, RenderJobRequest request) {
Map<String, Object> extra = new LinkedHashMap<>();
extra.put("scene", request.scene());
extra.put("referenceImageUrl", request.referenceImageUrl());
return enqueue(eventId, boothId, request.shotPreset(), extra);
}
@Override
public RenderJobDto publishReRoom(String eventId, String boothId, ReRoomJobRequest request) {
// ReRoomAI 방식 사진 시안 참조 이미지 필수. 워커는 reference_image(로컬경로) image-to-image.
String shotPreset = (request.shotPreset() == null || request.shotPreset().isBlank())
? "R1" : request.shotPreset();
Map<String, Object> extra = new LinkedHashMap<>();
extra.put("scene", request.scene() == null ? Map.of() : request.scene());
extra.put("referenceImageUrl", request.referenceImageUrl()); // 프론트 Before 표시용 공개 URL
extra.put("reference_image", request.referenceLocalPath()); // 워커 소비용 로컬 경로
extra.put("mode", "reroom");
extra.put("style", request.style());
extra.put("instruction", request.instruction());
return enqueue(eventId, boothId, shotPreset, extra);
}
/**
* RenderJob 발행 공통 경로(쿼터 적재상태 저장내구 이력). extra 워커 payload 병합된다.
* S1~S7·ReRoom(R1) 모두 경로로 발행되어 상태/콜백/푸시/쿼터를 동일하게 재사용한다.
*/
private RenderJobDto enqueue(String eventId, String boothId, String shotPreset,
Map<String, Object> extra) {
// 쿼터 정본 = DB 성공(DONE) 건수(RenderJobMapper.countSucceededByEvent). 성공 시에만 차감(§6-5). // 쿼터 정본 = DB 성공(DONE) 건수(RenderJobMapper.countSucceededByEvent). 성공 시에만 차감(§6-5).
long succeeded = renderJobMapper.countSucceededByEvent(eventId); long succeeded = renderJobMapper.countSucceededByEvent(eventId);
if (succeeded >= props.getEventQuotaDefault()) { if (succeeded >= props.getEventQuotaDefault()) {
@ -66,9 +94,10 @@ public class RenderJobServiceImpl implements RenderJobService {
jobPayload.put("jobId", jobId); jobPayload.put("jobId", jobId);
jobPayload.put("eventId", eventId); jobPayload.put("eventId", eventId);
jobPayload.put("boothId", boothId); jobPayload.put("boothId", boothId);
jobPayload.put("shotPreset", request.shotPreset()); jobPayload.put("shotPreset", shotPreset);
jobPayload.put("scene", request.scene()); if (extra != null) {
jobPayload.put("referenceImageUrl", request.referenceImageUrl()); jobPayload.putAll(extra); // scene/referenceImageUrl/reference_image/mode/style/instruction
}
jobPayload.put("meta", Map.of( jobPayload.put("meta", Map.of(
"watermarkRequired", true, "watermarkRequired", true,
"watermarkText", RenderJobDto.WATERMARK_TEXT, "watermarkText", RenderJobDto.WATERMARK_TEXT,
@ -81,7 +110,7 @@ public class RenderJobServiceImpl implements RenderJobService {
throw new ApiException(ErrorCode.INTERNAL, "RenderJob 직렬화에 실패했습니다."); throw new ApiException(ErrorCode.INTERNAL, "RenderJob 직렬화에 실패했습니다.");
} }
RenderJobDto dto = new RenderJobDto(jobId, boothId, request.shotPreset(), RenderJobDto dto = new RenderJobDto(jobId, boothId, shotPreset,
RenderJobStatus.QUEUED.name(), null, null, null, RenderJobStatus.QUEUED.name(), null, null, null,
true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE, null, now); true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE, null, now);
saveState(dto); saveState(dto);
@ -91,11 +120,11 @@ public class RenderJobServiceImpl implements RenderJobService {
params.put("jobId", jobId); params.put("jobId", jobId);
params.put("eventId", eventId); params.put("eventId", eventId);
params.put("boothId", boothId); params.put("boothId", boothId);
params.put("shotPreset", request.shotPreset()); params.put("shotPreset", shotPreset);
params.put("status", RenderJobStatus.QUEUED.name()); params.put("status", RenderJobStatus.QUEUED.name());
renderJobMapper.insertJob(params); renderJobMapper.insertJob(params);
log.info("RenderJob 발행: job={} booth={} shot={}", jobId, boothId, request.shotPreset()); log.info("RenderJob 발행: job={} booth={} shot={}", jobId, boothId, shotPreset);
return dto; return dto;
} }

View File

@ -0,0 +1,27 @@
package com.zioinfo.kintex.module.m5.dto;
import java.util.Map;
/**
* ReRoomAI 방식 사진 시안(image-to-image) 발행 스펙(내부용) 소유자 지시 13.2.
* 컨트롤러/스튜디오 서비스가 참조 이미지를 저장·해석한 스펙으로 RenderJobService에 발행한다.
*
* <p>referenceLocalPath 나노바나나 Python 워커가 소비하는 <b>로컬 파일 경로</b>(같은 서버 파일시스템),
* referenceImageUrl 프론트 Before 표시용 <b>공개 URL</b>(/uploads/reroom/). 둘은 분리된다.
*
* @param shotPreset 기본 R1(ReRoom). 워커가 reroom 경로로 라우팅
* @param scene 메타데이터/해시용 최소 (부스 컨텍스트)
* @param referenceLocalPath 워커 소비용 참조 이미지 로컬 절대경로
* @param referenceImageUrl 프론트 Before 표시용 공개 URL
* @param style ReRoom 스타일 id(modern/minimal/tech/luxury/korean_traditional/eco)
* @param instruction 사용자 자연어 지시(선택) 프롬프트 "교체" 파트에만 삽입
*/
public record ReRoomJobRequest(
String shotPreset,
Map<String, Object> scene,
String referenceLocalPath,
String referenceImageUrl,
String style,
String instruction
) {
}

View File

@ -0,0 +1,120 @@
package com.zioinfo.kintex.module.m5.reroom;
import com.zioinfo.kintex.auth.profile.ProfilePhotoService;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.module.m5.reroom.dto.ReferenceImageDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
/**
* ReRoomAI 방식 사진 시안의 참조 이미지 저장/해석 소유자 지시 13.2.
*
* <p>저장 컨벤션은 로그인 슬라이드/아바타와 동일: {@code {kintex.upload.dir}/reroom/} + <b>서버 UUID 파일명</b>.
* 원본 파일명·클라이언트 경로는 신뢰하지 않으므로 경로 순회는 구조적으로 불가능하다. 공개 서빙은
* {@code /uploads/reroom/**}(WebMvcConfig 정적 핸들러). 나노바나나 워커는 같은 파일시스템의 <b>로컬 절대경로</b>
* 참조 이미지를 소비한다.
*
* <p>검증(다층): 크기 상한(10MB) 매직바이트 실제 유형 판별(PNG/JPG/WebP, SVG·스크립트·위조 헤더 차단,
* {@link ProfilePhotoService#detectImageType} 재사용).
*/
@Component
public class ReRoomReferenceStore {
private static final Logger log = LoggerFactory.getLogger(ReRoomReferenceStore.class);
/** 참조 이미지 상한(10MB) — 프론트가 1024px/JPEG 0.85로 사전 다운스케일(ReRoomAI 전처리)하므로 실사용은 훨씬 작다. */
private static final long MAX_IMAGE_BYTES = 10L * 1024 * 1024;
/** 허용 선언 MIME 화이트리스트(불일치 시 매직바이트를 신뢰). */
private static final Map<String, String> ALLOWED_TYPES = Map.of(
"image/png", "png",
"image/jpeg", "jpg",
"image/webp", "webp");
private final Path reroomDir;
public ReRoomReferenceStore(@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
this.reroomDir = Paths.get(uploadDir).toAbsolutePath().normalize().resolve("reroom");
}
/** 참조 이미지 업로드(멀티파트) → {referenceId, url}. */
public ReferenceImageDto store(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ApiException(ErrorCode.VALIDATION, "참조 이미지 파일을 첨부해 주세요.");
}
byte[] bytes;
try {
bytes = file.getBytes();
} catch (IOException e) {
log.warn("reroom reference read failed: {}", e.getMessage());
throw new ApiException(ErrorCode.VALIDATION, "이미지를 읽을 수 없습니다.");
}
if (bytes.length == 0) {
throw new ApiException(ErrorCode.VALIDATION, "빈 이미지입니다.");
}
if (bytes.length > MAX_IMAGE_BYTES) {
throw new ApiException(ErrorCode.VALIDATION, "참조 이미지는 10MB 이하만 업로드할 수 있습니다.");
}
// 선언 타입이 있으면 화이트리스트 여부만 확인(불일치는 매직바이트를 신뢰).
String declared = file.getContentType();
String normalized = declared == null ? null : declared.toLowerCase(Locale.ROOT).trim();
if (normalized != null && !normalized.isBlank() && !ALLOWED_TYPES.containsKey(normalized)) {
throw new ApiException(ErrorCode.VALIDATION, "PNG/JPG/WebP 이미지만 업로드할 수 있습니다.");
}
// 매직바이트로 실제 유형 확정 SVG/스크립트/위조 헤더 차단.
String detected = ProfilePhotoService.detectImageType(bytes);
if (detected == null) {
throw new ApiException(ErrorCode.VALIDATION, "PNG/JPG/WebP 이미지만 업로드할 수 있습니다.");
}
String fileName = UUID.randomUUID() + "." + detected; // 서버 생성 파일명(원본명 미신뢰)
try {
Files.createDirectories(reroomDir);
Path target = reroomDir.resolve(fileName).normalize();
if (!target.startsWith(reroomDir)) { // 파일명이 UUID라 사실상 불변식이나 방어적 확인
throw new ApiException(ErrorCode.VALIDATION, "잘못된 파일 경로입니다.");
}
Files.write(target, bytes);
} catch (IOException e) {
log.error("reroom reference store failed: {}", e.getMessage());
throw new ApiException(ErrorCode.INTERNAL, "이미지 저장에 실패했습니다.");
}
return new ReferenceImageDto(fileName, publicUrl(fileName));
}
/** referenceId(파일명) → 워커 소비용 로컬 절대경로. 안전성 검증 후 실재 파일만 통과. */
public String resolveLocalPath(String referenceId) {
String fileName = requireSafe(referenceId);
Path target = reroomDir.resolve(fileName).normalize();
if (!target.startsWith(reroomDir) || !Files.isRegularFile(target)) {
throw new ApiException(ErrorCode.NOT_FOUND, "참조 이미지를 찾을 수 없습니다.");
}
return target.toString();
}
/** referenceId → 프론트 Before 표시용 공개 URL. */
public String publicUrl(String referenceId) {
return "/uploads/reroom/" + requireSafe(referenceId);
}
// 경로 순회/구분자 차단 referenceId 서버가 UUID로 발급하므로 위조 시도만 거른다.
private static String requireSafe(String name) {
if (name == null || name.isBlank()
|| name.contains("/") || name.contains("\\") || name.contains("..")) {
throw new ApiException(ErrorCode.VALIDATION, "잘못된 참조 이미지 식별자입니다.");
}
return name;
}
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.kintex.module.m5.reroom;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.module.m5.dto.RenderJobDto;
import com.zioinfo.kintex.module.m5.reroom.dto.ReRoomRenderRequest;
import com.zioinfo.kintex.module.m5.reroom.dto.ReferenceImageDto;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* M5 ReRoomAI 방식 사진 시안 참조 이미지 업로드 + image-to-image 렌더 발행(SCR-06 "사진 시안" 섹션).
* 소유자 지시 13.2. 행사 RBAC 가드. 상태 조회/콜백/푸시는 기존 RenderJob 경로 재사용
* (GET /api/events/{eventId}/render-jobs/{jobId}).
*/
@RestController
@RequestMapping("/api/events/{eventId}/booths/{boothId}/reroom")
public class ReRoomStudioController {
private final ReRoomStudioService service;
private final EventAccessGuard guard;
public ReRoomStudioController(ReRoomStudioService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** POST /reference — 빈 부스/공간 참조 이미지 업로드(multipart {@code file}) → {referenceId, url}. */
@PostMapping(value = "/reference", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<ReferenceImageDto> uploadReference(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@PathVariable String boothId,
@RequestPart("file") MultipartFile file) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.uploadReference(file));
}
/** POST /render — 사진 시안 렌더 발행(참조ID·스타일·자연어 지시) → RenderJobDto(QUEUED). */
@PostMapping("/render")
public ApiResponse<RenderJobDto> render(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@PathVariable String boothId,
@Valid @RequestBody ReRoomRenderRequest request) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.render(eventId, boothId, request));
}
}

View File

@ -0,0 +1,71 @@
package com.zioinfo.kintex.module.m5.reroom;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.module.m5.RenderJobService;
import com.zioinfo.kintex.module.m5.dto.ReRoomJobRequest;
import com.zioinfo.kintex.module.m5.dto.RenderJobDto;
import com.zioinfo.kintex.module.m5.reroom.dto.ReRoomRenderRequest;
import com.zioinfo.kintex.module.m5.reroom.dto.ReferenceImageDto;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* ReRoomAI 방식 사진 시안(image-to-image) 오케스트레이션 소유자 지시 13.2.
*
* <p>흐름: 참조 이미지 업로드({@link ReRoomReferenceStore}) 스타일/지시 검증 참조 로컬경로·공개URL 해석
* {@link RenderJobService#publishReRoom} 발행(상태/콜백/푸시/쿼터 재사용). 나노바나나 실호출은 Python 워커 담당.
*
* <p>보안: GEMINI_API_KEY 백엔드에서 다루지 않는다. style 서버 화이트리스트로만 허용,
* instruction 프롬프트 "교체" 파트에만 삽입되며 워커가 보존(구조) 지시를 고정한다.
*/
@Service
public class ReRoomStudioService {
/** ReRoom 스타일 화이트리스트 — prompts/reroom_styles.json 키와 정합(워커 단일 출처). */
private static final Set<String> ALLOWED_STYLES = Set.of(
"modern", "minimal", "tech", "luxury", "korean_traditional", "eco");
private final ReRoomReferenceStore referenceStore;
private final RenderJobService renderJobService;
public ReRoomStudioService(ReRoomReferenceStore referenceStore, RenderJobService renderJobService) {
this.referenceStore = referenceStore;
this.renderJobService = renderJobService;
}
/** 참조 이미지 업로드 → {referenceId, url}. */
public ReferenceImageDto uploadReference(MultipartFile file) {
return referenceStore.store(file);
}
/** 사진 시안 렌더 발행 → RenderJobDto(QUEUED). 상태는 기존 GET /render-jobs/{jobId} 로 폴링. */
public RenderJobDto render(String eventId, String boothId, ReRoomRenderRequest request) {
String style = request.style() == null ? "" : request.style().trim();
if (!ALLOWED_STYLES.contains(style)) {
throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 스타일입니다.");
}
// 참조 이미지 해석(존재 검증 포함) 워커 소비용 로컬경로 + 프론트 표시용 공개 URL.
String localPath = referenceStore.resolveLocalPath(request.referenceId());
String publicUrl = referenceStore.publicUrl(request.referenceId());
// 메타데이터/해시용 최소 (부스 컨텍스트). 프롬프트는 워커의 reroom 경로가 style/instruction 으로 조립.
Map<String, Object> scene = new LinkedHashMap<>();
scene.put("reroom", true);
scene.put("style", style);
Map<String, Object> booth = new LinkedHashMap<>();
booth.put("id", boothId);
scene.put("booth", booth);
String instruction = request.instruction() == null ? null : request.instruction().trim();
ReRoomJobRequest job = new ReRoomJobRequest(
"R1", scene, localPath, publicUrl, style,
instruction == null || instruction.isBlank() ? null : instruction);
return renderJobService.publishReRoom(eventId, boothId, job);
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.kintex.module.m5.reroom.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* ReRoomAI 방식 사진 시안 렌더 발행 요청(SCR-06 "사진 시안" 섹션) 소유자 지시 13.2.
*
* @param referenceId 업로드 참조 이미지 식별자(/reference 응답의 referenceId)
* @param style ReRoom 스타일 id(허용값은 서버 화이트리스트로 검증)
* @param instruction 자연어 지시(선택) 프롬프트 "교체" 파트에만 삽입. 길이 제한
*/
public record ReRoomRenderRequest(
@NotBlank String referenceId,
@NotBlank String style,
@Size(max = 500) String instruction
) {
}

View File

@ -0,0 +1,10 @@
package com.zioinfo.kintex.module.m5.reroom.dto;
/**
* 참조 이미지 업로드 결과 ReRoomAI 방식 사진 시안(소유자 지시 13.2).
*
* @param referenceId 서버 생성 식별자(UUID.확장자) 경로 순회 불가(원본 파일명 미신뢰)
* @param url 프론트 Before 표시용 공개 URL(/uploads/reroom/)
*/
public record ReferenceImageDto(String referenceId, String url) {
}

View File

@ -19,8 +19,8 @@ spring:
name: kintex-backend name: kintex-backend
servlet: servlet:
multipart: multipart:
max-file-size: 8MB # 로그인 슬라이드 등 이미지 업로드 상한 max-file-size: 10MB # 로그인 슬라이드·ReRoom 참조 이미지 업로드 상한(사진 시안, 소유자 지시 13.2)
max-request-size: 10MB max-request-size: 12MB
datasource: datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/kintex_db} url: ${DB_URL:jdbc:postgresql://localhost:5432/kintex_db}
username: ${DB_USER:kintex} username: ${DB_USER:kintex}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { designApi, renderApi } from '../../api/endpoints'; import { designApi, renderApi } from '../../api/endpoints';
@ -9,6 +9,12 @@ import { AiImage } from '../../components/ui/AiImage';
import { CompareSlider } from '../../components/ui/CompareSlider'; import { CompareSlider } from '../../components/ui/CompareSlider';
import type { ComplianceReport, DesignSpec, RenderJobDto, RenderJobRequest } from '../../api/types'; import type { ComplianceReport, DesignSpec, RenderJobDto, RenderJobRequest } from '../../api/types';
import { AFTER_PLACEHOLDER, AFTER_SAMPLE, BEFORE_EMPTY_HALL, MOCK_SHOT_IMG } from './placeholders'; import { AFTER_PLACEHOLDER, AFTER_SAMPLE, BEFORE_EMPTY_HALL, MOCK_SHOT_IMG } from './placeholders';
import {
REROOM_STYLES,
downscaleImage,
reroomApi,
MAX_UPLOAD_BYTES,
} from './reroomApi';
import './studio.css'; import './studio.css';
/* /*
@ -65,6 +71,18 @@ export function BoothDesignStudioPage() {
const [confirmAgree, setConfirmAgree] = useState(false); const [confirmAgree, setConfirmAgree] = useState(false);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
// ── 사진 시안(ReRoom, image-to-image) — 소유자 지시 13.2 ──
const fileInputRef = useRef<HTMLInputElement>(null);
const [refPreview, setRefPreview] = useState<string | null>(null); // 로컬 objectURL(Before)
const [refId, setRefId] = useState<string | null>(null); // 서버 참조 이미지 id
const [refUploading, setRefUploading] = useState(false);
const [reroomStyle, setReroomStyle] = useState<string>('modern');
const [reroomInstruction, setReroomInstruction] = useState('');
const [reroomJob, setReroomJob] = useState<RenderJobDto | null>(null);
const [reroomBusy, setReroomBusy] = useState(false);
const [reroomError, setReroomError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
// 초기 설계안 로드(501이면 degraded 기본 스펙 유지). // 초기 설계안 로드(501이면 degraded 기본 스펙 유지).
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@ -93,6 +111,91 @@ export function BoothDesignStudioPage() {
return () => unsubs.forEach((u) => u()); return () => unsubs.forEach((u) => u());
}, [jobs]); }, [jobs]);
// 사진 시안 잡: WebSocket 구독 + 폴백 폴링(완료/실패까지).
useEffect(() => {
const jobId = reroomJob?.jobId;
if (!jobId || reroomJob?.status === 'DONE' || reroomJob?.status === 'FAILED') return;
const unsub = subscribeRenderJob(jobId, (job) => setReroomJob(job));
const timer = window.setInterval(async () => {
try {
setReroomJob(await reroomApi.status(eventId, jobId));
} catch {
/* 폴백 폴링 실패는 무시(WebSocket 우선) */
}
}, 4000);
return () => {
unsub();
window.clearInterval(timer);
};
}, [reroomJob?.jobId, reroomJob?.status, eventId]);
// 언마운트 시 미리보기 objectURL 정리.
useEffect(() => {
return () => {
if (refPreview) URL.revokeObjectURL(refPreview);
};
}, [refPreview]);
// 참조 이미지 선택 → 1024px 다운스케일 → 즉시 업로드(referenceId 확보).
const handleRefFile = useCallback(
async (file: File | undefined | null) => {
if (!file) return;
setReroomError(null);
if (!file.type.startsWith('image/')) {
setReroomError('이미지 파일만 업로드할 수 있습니다.');
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setReroomError('참조 이미지는 10MB 이하만 업로드할 수 있습니다.');
return;
}
setRefUploading(true);
setReroomJob(null);
setRefId(null);
try {
const blob = await downscaleImage(file);
setRefPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
const res = await reroomApi.uploadReference(eventId, boothId, blob);
setRefId(res.referenceId);
} catch (err) {
setReroomError(
err instanceof ApiRequestError ? err.message : '참조 이미지 업로드에 실패했습니다.',
);
} finally {
setRefUploading(false);
}
},
[eventId, boothId],
);
const generateReroom = useCallback(async () => {
if (!refId) {
setReroomError('먼저 빈 부스/공간 사진을 업로드하세요.');
return;
}
setReroomBusy(true);
setReroomError(null);
try {
const job = await reroomApi.render(eventId, boothId, {
referenceId: refId,
style: reroomStyle,
instruction: reroomInstruction.trim() || undefined,
});
setReroomJob(job);
} catch (err) {
if (err instanceof ApiRequestError && err.code === 'RENDER_QUOTA_EXCEEDED') {
setReroomError('행사 이미지 생성 쿼터가 소진되었습니다.');
} else {
setReroomError(err instanceof ApiRequestError ? err.message : '시안 생성 중 오류가 발생했습니다.');
}
} finally {
setReroomBusy(false);
}
}, [eventId, boothId, refId, reroomStyle, reroomInstruction]);
const runPrecheck = useCallback(async () => { const runPrecheck = useCallback(async () => {
setPrechecking(true); setPrechecking(true);
setNotice(null); setNotice(null);
@ -424,6 +527,158 @@ export function BoothDesignStudioPage() {
</section> </section>
</div> </div>
{/* 사진 시안(AI) — ReRoomAI 방식 image-to-image (소유자 지시 13.2) */}
<section className="kx-reroom" aria-label="사진 시안 (AI)">
<div className="kx-reroom__head">
<AiLabel> (AI)</AiLabel>
<p className="kx-reroom__sub">
· · .
, .
</p>
</div>
<div className="kx-reroom__body">
{/* 01 업로드 */}
<div className="kx-reroom__col">
<span className="kx-reroom__step">01 </span>
<div
className={`kx-reroom__drop${dragOver ? ' is-over' : ''}${
refPreview ? ' has-img' : ''
}`}
role="button"
tabIndex={0}
aria-label="참조 이미지 업로드"
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
void handleRefFile(e.dataTransfer.files?.[0]);
}}
>
{refPreview ? (
<img src={refPreview} alt="참조 이미지 미리보기" className="kx-reroom__preview" />
) : (
<span className="kx-reroom__drop-hint">
<span className="kx-reroom__drop-plus" aria-hidden="true">
</span>
<small>JPG · PNG · WebP · 10MB </small>
</span>
)}
{refUploading && (
<span className="kx-reroom__uploading" aria-live="polite">
</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(e) => void handleRefFile(e.target.files?.[0])}
/>
</div>
{/* 02 스타일 + 03 지시 + 생성 */}
<div className="kx-reroom__col kx-reroom__col--wide">
<span className="kx-reroom__step">02 </span>
<div className="kx-reroom__styles" role="radiogroup" aria-label="시안 스타일">
{REROOM_STYLES.map((s) => (
<button
key={s.id}
type="button"
role="radio"
aria-checked={reroomStyle === s.id}
className={`kx-reroom__style${reroomStyle === s.id ? ' is-active' : ''}`}
onClick={() => setReroomStyle(s.id)}
>
<span className="kx-reroom__swatch" aria-hidden="true">
{s.swatch.map((c, i) => (
<i key={i} style={{ background: c }} />
))}
</span>
{s.label}
</button>
))}
</div>
<span className="kx-reroom__step">03 ()</span>
<textarea
className="kx-reroom__instruction"
rows={2}
maxLength={500}
placeholder="예: 파란색 브랜드 월과 로봇 데모 존을 강조해줘"
value={reroomInstruction}
onChange={(e) => setReroomInstruction(e.target.value)}
/>
<div className="kx-reroom__actions">
<Button
variant="ai"
leadingIcon="✦"
onClick={generateReroom}
disabled={!refId || refUploading || reroomBusy}
>
{reroomBusy ? '시안 생성 요청 중…' : '사진 시안 생성'}
</Button>
{reroomError && (
<p className="kx-reroom__err" role="status">
{reroomError}
</p>
)}
</div>
</div>
</div>
{/* 결과 Before/After */}
{reroomJob && (
<div className="kx-reroom__result">
{reroomJob.status === 'DONE' && reroomJob.imageUrl && refPreview ? (
<CompareSlider
beforeSrc={refPreview}
afterSrc={reroomJob.imageUrl}
beforeLabel="업로드(빈 공간)"
afterLabel="AI 시안"
afterOverlay={
<>
<div className="kx-studio__wm" aria-hidden="true">
{reroomJob.watermarkText}
</div>
<span className="kx-studio__after-ai">
<AiLabel>AI </AiLabel>
</span>
</>
}
/>
) : (
<AiImage
imageUrl={reroomJob.imageUrl}
status={reroomJob.status}
watermarkText={reroomJob.watermarkText}
notice={reroomJob.notice}
shotLabel="사진 시안"
onRetry={generateReroom}
alt="사진 시안 예상 이미지"
/>
)}
<p className="kx-reroom__notice">{reroomJob.notice}</p>
</div>
)}
</section>
{/* 하단 고정 바: 규정 사전검증 요약 + 컨펌 CTA */} {/* 하단 고정 바: 규정 사전검증 요약 + 컨펌 CTA */}
<footer className="kx-studio__footer"> <footer className="kx-studio__footer">
<div className="kx-studio__precheck"> <div className="kx-studio__precheck">

View File

@ -0,0 +1,94 @@
/*
* ReRoomAI (image-to-image) API SCR-06 "사진 시안" ( ).
* 13.2. 근거: docs/analysis/reroomai-source.md §4~5(/ ·1024px ).
*
* endpoints.ts/types.ts .
* RenderJob (GET /render-jobs/{jobId}) .
*/
import { api } from '../../api/client';
import type { RenderJobDto } from '../../api/types';
export interface ReferenceImage {
referenceId: string;
url: string;
}
export interface ReRoomStyle {
id: string;
label: string;
swatch: [string, string, string];
}
/** ReRoom 스타일 카드 — 워커 prompts/reroom_styles.json 키와 정합(라벨은 한국어 하드코딩, 이 화면 컨벤션). */
export const REROOM_STYLES: ReRoomStyle[] = [
{ id: 'modern', label: '모던', swatch: ['#2b2b2e', '#8a8f98', '#f2f2f0'] },
{ id: 'minimal', label: '미니멀', swatch: ['#ffffff', '#d8d8d8', '#333333'] },
{ id: 'tech', label: '테크', swatch: ['#0a0e27', '#00d4ff', '#e6f7ff'] },
{ id: 'luxury', label: '럭셔리', swatch: ['#1a1a1a', '#c9a24b', '#f4f1ea'] },
{ id: 'korean_traditional', label: '한국 전통', swatch: ['#8a5a2b', '#c8b18a', '#2f3b30'] },
{ id: 'eco', label: '친환경', swatch: ['#2f4f3e', '#a8c686', '#f2efe6'] },
];
const base = (eventId: string, boothId: string) =>
`/api/events/${encodeURIComponent(eventId)}/booths/${encodeURIComponent(boothId)}/reroom`;
export const reroomApi = {
/** 참조 이미지 업로드(multipart file) → {referenceId, url}. */
uploadReference: (eventId: string, boothId: string, file: Blob) => {
const form = new FormData();
form.append('file', file, 'reference.jpg');
return api.postForm<ReferenceImage>(`${base(eventId, boothId)}/reference`, form);
},
/** 사진 시안 렌더 발행 → RenderJobDto(QUEUED). */
render: (
eventId: string,
boothId: string,
body: { referenceId: string; style: string; instruction?: string },
) => api.post<RenderJobDto>(`${base(eventId, boothId)}/render`, body),
/** 상태 폴링(기존 RenderJob 상태 엔드포인트 재사용). */
status: (eventId: string, jobId: string) =>
api.get<RenderJobDto>(
`/api/events/${encodeURIComponent(eventId)}/render-jobs/${encodeURIComponent(jobId)}`,
),
};
/**
* (ReRoomAI Studio.handleImageFile ) 1024px, JPEG 0.85.
* · · . Blob ().
*/
export const MAX_SIDE = 1024;
export const JPEG_QUALITY = 0.85;
export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
export function downscaleImage(file: File): Promise<Blob> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const longSide = Math.max(img.width, img.height);
const scale = longSide > MAX_SIDE ? MAX_SIDE / longSide : 1;
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(file);
return;
}
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob(
(blob) => resolve(blob ?? file),
'image/jpeg',
JPEG_QUALITY,
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(file);
};
img.src = url;
});
}

View File

@ -344,3 +344,165 @@
overflow-y: visible; overflow-y: visible;
} }
} }
/* ── 사진 시안(AI) — ReRoomAI 방식 image-to-image (소유자 지시 13.2) ── */
.kx-reroom {
margin: var(--space-4);
padding: var(--space-4);
background: var(--color-ai-surface);
border: 1px solid var(--color-ai-accent);
border-radius: var(--radius-lg);
}
.kx-reroom__head {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.kx-reroom__sub {
margin: 0;
font-size: var(--fs-caption);
color: var(--color-neutral-700);
}
.kx-reroom__body {
display: grid;
grid-template-columns: minmax(220px, 320px) 1fr;
gap: var(--space-4);
}
.kx-reroom__col {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.kx-reroom__step {
font-size: var(--fs-micro);
font-weight: var(--fw-semibold);
color: var(--color-neutral-500);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.kx-reroom__drop {
position: relative;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 16 / 10;
border: 1.5px dashed var(--color-neutral-200);
border-radius: var(--radius-lg);
background: var(--color-white);
cursor: pointer;
overflow: hidden;
transition: border-color 0.15s ease, background 0.15s ease;
}
.kx-reroom__drop.is-over {
border-color: var(--color-ai-accent);
background: var(--color-primary-050);
}
.kx-reroom__drop.has-img {
border-style: solid;
}
.kx-reroom__drop-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--space-3);
text-align: center;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-reroom__drop-plus {
font-size: var(--fs-h2);
line-height: 1;
color: var(--color-ai-accent);
}
.kx-reroom__drop-hint small {
font-size: var(--fs-micro);
color: var(--color-neutral-500);
}
.kx-reroom__preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.kx-reroom__uploading {
position: absolute;
inset: auto 0 0 0;
padding: 4px var(--space-2);
font-size: var(--fs-micro);
color: var(--color-white);
background: rgba(20, 22, 28, 0.72);
text-align: center;
}
.kx-reroom__col--wide {
gap: var(--space-3);
}
.kx-reroom__styles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--space-2);
}
.kx-reroom__style {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
background: var(--color-white);
font-size: var(--fs-caption);
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.kx-reroom__style.is-active {
border-color: var(--color-ai-accent);
box-shadow: 0 0 0 1px var(--color-ai-accent) inset;
font-weight: var(--fw-semibold);
}
.kx-reroom__swatch {
display: inline-flex;
border-radius: var(--radius-sm);
overflow: hidden;
flex: none;
}
.kx-reroom__swatch i {
display: block;
width: 12px;
height: 20px;
}
.kx-reroom__instruction {
width: 100%;
padding: var(--space-2);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
font: inherit;
font-size: var(--fs-caption);
resize: vertical;
}
.kx-reroom__actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-reroom__err {
margin: 0;
font-size: var(--fs-caption);
color: var(--color-error);
}
.kx-reroom__result {
margin-top: var(--space-4);
max-width: 720px;
}
.kx-reroom__notice {
margin: var(--space-2) 0 0;
font-size: var(--fs-micro);
color: var(--color-neutral-500);
}
@media (max-width: 760px) {
.kx-reroom__body {
grid-template-columns: 1fr;
}
}

View File

@ -196,12 +196,127 @@ SHOT_PRESETS: dict[str, dict[str, Any]] = {
"mode": "day", "mode": "day",
"generative": True, "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과 동일) #: S6 배선 색상 규약 (design.md §1-2 / PLANNING §6-3과 동일)
WIRING_COLORS = {"power": "red", "network": "blue", "plumbing": "green"} 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단 조립 # 프롬프트 빌더 — "보존 잠금 / 교체 지정 / 사진 품질" 3단 조립
# ============================================================================ # ============================================================================
@ -684,6 +799,32 @@ class NanoBananaClient:
scene, shot_preset=shot, reference_image=reference_image, seed=seed 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 생성형 오버레이 (발표/설명용 보조 — 시공 검증 아님) # S6 생성형 오버레이 (발표/설명용 보조 — 시공 검증 아님)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@ -0,0 +1,24 @@
ReRoomAI 방식 사진 시안(image-to-image) 프롬프트 템플릿 — 참고용 단일 출처.
실제 조립은 client.build_reroom_prompt()가 수행한다(이 파일은 구조 문서·검수용).
근거: docs/analysis/reroomai-source.md §4 "보존/교체 명시적 분리" 4단 구조.
① 대상 + 스타일
Photorealistic professional photograph of this exhibition booth space at the KINTEX
convention center in South Korea, shown as if construction is complete.
Redesign it in this style: {style_prompt}.
② 보존 잠금 (고정 — 자연어 지시로 덮어쓰지 않음)
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.
③ 교체 지정 (자연어 지시는 오직 이 파트에만 삽입)
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.
Additional client direction (apply to the styling only): {instruction}
④ 사진 품질
Photorealistic tradeshow photography, {time_of_day} 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.

View File

@ -0,0 +1,32 @@
{
"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"
}
}

View File

@ -131,6 +131,15 @@ def _placeholder_png(lines: list[str], size: tuple[int, int] = (1024, 640)) -> t
# ============================================================================ # ============================================================================
# RenderJob / RenderResult 계약 객체 (계약 §2/§3) # 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 @dataclass
class RenderJob: class RenderJob:
job_id: str job_id: str
@ -144,23 +153,33 @@ class RenderJob:
wiring: Optional[dict] = None wiring: Optional[dict] = None
hall_dims_m: Optional[list[float]] = None hall_dims_m: Optional[list[float]] = None
options: dict = field(default_factory=dict) 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 @classmethod
def from_dict(cls, d: dict) -> "RenderJob": def from_dict(cls, d: dict) -> "RenderJob":
if not d.get("job_id"): # 백엔드는 camelCase(jobId/shotPreset…)로 발행 — snake_case 워커 계약과 양립하도록 관대 파싱.
job_id = _first(d, "job_id", "jobId")
if not job_id:
raise NanoBananaError("RenderJob.job_id 누락") raise NanoBananaError("RenderJob.job_id 누락")
return cls( return cls(
job_id=str(d["job_id"]), job_id=str(job_id),
shot_preset=d.get("shot_preset", "S1"), shot_preset=_first(d, "shot_preset", "shotPreset", default="S1"),
event_id=d.get("event_id"), event_id=_first(d, "event_id", "eventId"),
booth_id=d.get("booth_id"), booth_id=_first(d, "booth_id", "boothId"),
scene=d.get("scene") or {}, scene=_first(d, "scene", default={}) or {},
seed=d.get("seed"), seed=_first(d, "seed"),
reference_image=d.get("reference_image"), # reference_image=워커 소비용 로컬 경로, referenceImageUrl=프론트 표시용 URL.
layers=d.get("layers"), reference_image=_first(d, "reference_image", "referenceImage", "referenceImageUrl"),
wiring=d.get("wiring"), layers=_first(d, "layers"),
hall_dims_m=d.get("hall_dims_m"), wiring=_first(d, "wiring"),
options=d.get("options") or {}, 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"),
) )
@ -264,20 +283,88 @@ class RenderWorker:
# 렌더 디스패치 # 렌더 디스패치
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _render(self, job: RenderJob) -> GeneratedImage: def _render(self, job: RenderJob) -> GeneratedImage:
"""샷 프리셋에 따라 (a) S6 래스터 합성, (b) 생성형/목 렌더로 분기.""" """샷 프리셋에 따라 (a) S6 래스터 합성, (b) ReRoom 사진 시안, (c) 생성형/목 렌더로 분기."""
preset = SHOT_PRESETS.get(job.shot_preset) preset = SHOT_PRESETS.get(job.shot_preset)
if preset is None: if preset is None:
raise NanoBananaError(f"알 수 없는 샷 프리셋: {job.shot_preset} (S1~S7)") raise NanoBananaError(f"알 수 없는 샷 프리셋: {job.shot_preset} (S1~S7,R1)")
# (a) S6 배선 오버레이 — 생성형 아님, 항상 로컬 PIL 결정적 합성(M5-3) # (a) S6 배선 오버레이 — 생성형 아님, 항상 로컬 PIL 결정적 합성(M5-3)
if not preset.get("generative", True): if not preset.get("generative", True):
return self._render_wiring(job) return self._render_wiring(job)
# (b) 생성형 샷(S1~S5,S7) # (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(): if is_live():
return self._render_live(job) # G1 승인 경로 return self._render_live(job) # G1 승인 경로
return self._render_mock(job) # 기본: 목/degraded 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: def _render_wiring(self, job: RenderJob) -> GeneratedImage:
"""S6: scene.wiring/최상위 wiring → 좌표 정합 래스터 오버레이(client 재사용).""" """S6: scene.wiring/최상위 wiring → 좌표 정합 래스터 오버레이(client 재사용)."""
scene_inner = job.scene.get("scene", job.scene) scene_inner = job.scene.get("scene", job.scene)
@ -476,6 +563,27 @@ def _smoke() -> int:
else: else:
print(f"[smoke] S6 skipped (환경 제약): {r2.error}") 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(f"[smoke] output dir: {tmp}")
print("[smoke] RESULT:", "PASS" if ok else "FAIL") print("[smoke] RESULT:", "PASS" if ok else "FAIL")
return 0 if ok else 1 return 0 if ok else 1