From 0f91dcf4aecec400454fc2d7c7b270b7f7850301 Mon Sep 17 00:00:00 2001 From: zio Date: Tue, 14 Jul 2026 00:25:23 +0900 Subject: [PATCH] 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 --- .../kintex/module/m5/RenderJobService.java | 8 + .../module/m5/RenderJobServiceImpl.java | 41 ++- .../module/m5/dto/ReRoomJobRequest.java | 27 ++ .../m5/reroom/ReRoomReferenceStore.java | 120 ++++++++ .../m5/reroom/ReRoomStudioController.java | 51 ++++ .../module/m5/reroom/ReRoomStudioService.java | 71 +++++ .../m5/reroom/dto/ReRoomRenderRequest.java | 18 ++ .../m5/reroom/dto/ReferenceImageDto.java | 10 + .../src/main/resources/application.yml | 4 +- .../screens/design/BoothDesignStudioPage.tsx | 257 +++++++++++++++++- src/frontend/src/screens/design/reroomApi.ts | 94 +++++++ src/frontend/src/screens/design/studio.css | 162 +++++++++++ tools/nanobanana/client.py | 141 ++++++++++ tools/nanobanana/prompts/reroom_photo.txt | 24 ++ tools/nanobanana/prompts/reroom_styles.json | 32 +++ tools/nanobanana/worker.py | 138 +++++++++- 16 files changed, 1174 insertions(+), 24 deletions(-) create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m5/dto/ReRoomJobRequest.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomReferenceStore.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomStudioController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomStudioService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/dto/ReRoomRenderRequest.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/dto/ReferenceImageDto.java create mode 100644 src/frontend/src/screens/design/reroomApi.ts create mode 100644 tools/nanobanana/prompts/reroom_photo.txt create mode 100644 tools/nanobanana/prompts/reroom_styles.json diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobService.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobService.java index 2a11878..2849b95 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobService.java @@ -1,5 +1,6 @@ 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.RenderJobRequest; import com.zioinfo.kintex.module.m5.dto.WorkerCallbackRequest; @@ -12,6 +13,13 @@ public interface RenderJobService { /** RenderJob 발행 — 쿼터 확인 후 Redis 큐에 적재. 상태 QUEUED로 반환. */ 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); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobServiceImpl.java index d86b7b4..be6e52d 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/RenderJobServiceImpl.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.zioinfo.kintex.common.error.ApiException; 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.RenderJobRequest; import com.zioinfo.kintex.module.m5.dto.WorkerCallbackRequest; @@ -51,6 +52,33 @@ public class RenderJobServiceImpl implements RenderJobService { @Override public RenderJobDto publish(String eventId, String boothId, RenderJobRequest request) { + Map 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 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 extra) { // 쿼터 정본 = DB 성공(DONE) 건수(RenderJobMapper.countSucceededByEvent). 성공 시에만 차감(§6-5). long succeeded = renderJobMapper.countSucceededByEvent(eventId); if (succeeded >= props.getEventQuotaDefault()) { @@ -66,9 +94,10 @@ public class RenderJobServiceImpl implements RenderJobService { jobPayload.put("jobId", jobId); jobPayload.put("eventId", eventId); jobPayload.put("boothId", boothId); - jobPayload.put("shotPreset", request.shotPreset()); - jobPayload.put("scene", request.scene()); - jobPayload.put("referenceImageUrl", request.referenceImageUrl()); + jobPayload.put("shotPreset", shotPreset); + if (extra != null) { + jobPayload.putAll(extra); // scene/referenceImageUrl/reference_image/mode/style/instruction 등 + } jobPayload.put("meta", Map.of( "watermarkRequired", true, "watermarkText", RenderJobDto.WATERMARK_TEXT, @@ -81,7 +110,7 @@ public class RenderJobServiceImpl implements RenderJobService { 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, true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE, null, now); saveState(dto); @@ -91,11 +120,11 @@ public class RenderJobServiceImpl implements RenderJobService { params.put("jobId", jobId); params.put("eventId", eventId); params.put("boothId", boothId); - params.put("shotPreset", request.shotPreset()); + params.put("shotPreset", shotPreset); params.put("status", RenderJobStatus.QUEUED.name()); renderJobMapper.insertJob(params); - log.info("RenderJob 발행: job={} booth={} shot={}", jobId, boothId, request.shotPreset()); + log.info("RenderJob 발행: job={} booth={} shot={}", jobId, boothId, shotPreset); return dto; } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/dto/ReRoomJobRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/dto/ReRoomJobRequest.java new file mode 100644 index 0000000..a7bddd9 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/dto/ReRoomJobRequest.java @@ -0,0 +1,27 @@ +package com.zioinfo.kintex.module.m5.dto; + +import java.util.Map; + +/** + * ReRoomAI 방식 사진 시안(image-to-image) 발행 스펙(내부용) — 소유자 지시 13.2. + * 컨트롤러/스튜디오 서비스가 참조 이미지를 저장·해석한 뒤 이 스펙으로 RenderJobService에 발행한다. + * + *

referenceLocalPath 는 나노바나나 Python 워커가 소비하는 로컬 파일 경로(같은 서버 파일시스템), + * referenceImageUrl 은 프론트 Before 표시용 공개 URL(/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 scene, + String referenceLocalPath, + String referenceImageUrl, + String style, + String instruction +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomReferenceStore.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomReferenceStore.java new file mode 100644 index 0000000..646db2b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomReferenceStore.java @@ -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. + * + *

저장 컨벤션은 로그인 슬라이드/아바타와 동일: {@code {kintex.upload.dir}/reroom/} + 서버 UUID 파일명. + * 원본 파일명·클라이언트 경로는 신뢰하지 않으므로 경로 순회는 구조적으로 불가능하다. 공개 서빙은 + * {@code /uploads/reroom/**}(WebMvcConfig 정적 핸들러). 나노바나나 워커는 같은 파일시스템의 로컬 절대경로로 + * 참조 이미지를 소비한다. + * + *

검증(다층): ①크기 상한(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 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; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomStudioController.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomStudioController.java new file mode 100644 index 0000000..e7fa614 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomStudioController.java @@ -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 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 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)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomStudioService.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomStudioService.java new file mode 100644 index 0000000..cd5797b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/ReRoomStudioService.java @@ -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. + * + *

흐름: 참조 이미지 업로드({@link ReRoomReferenceStore}) → 스타일/지시 검증 → 참조 로컬경로·공개URL 해석 → + * {@link RenderJobService#publishReRoom} 로 발행(상태/콜백/푸시/쿼터 재사용). 나노바나나 실호출은 Python 워커 담당. + * + *

보안: GEMINI_API_KEY 는 백엔드에서 다루지 않는다. style 은 서버 화이트리스트로만 허용, + * instruction 은 프롬프트 "교체" 파트에만 삽입되며 워커가 보존(구조) 지시를 고정한다. + */ +@Service +public class ReRoomStudioService { + + /** ReRoom 스타일 화이트리스트 — prompts/reroom_styles.json 키와 정합(워커 단일 출처). */ + private static final Set 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 scene = new LinkedHashMap<>(); + scene.put("reroom", true); + scene.put("style", style); + Map 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); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/dto/ReRoomRenderRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/dto/ReRoomRenderRequest.java new file mode 100644 index 0000000..c95b330 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/dto/ReRoomRenderRequest.java @@ -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 +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/dto/ReferenceImageDto.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/dto/ReferenceImageDto.java new file mode 100644 index 0000000..4e6b40c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m5/reroom/dto/ReferenceImageDto.java @@ -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) { +} diff --git a/src/backend/src/main/resources/application.yml b/src/backend/src/main/resources/application.yml index 8610f96..859a58e 100644 --- a/src/backend/src/main/resources/application.yml +++ b/src/backend/src/main/resources/application.yml @@ -19,8 +19,8 @@ spring: name: kintex-backend servlet: multipart: - max-file-size: 8MB # 로그인 슬라이드 등 이미지 업로드 상한 - max-request-size: 10MB + max-file-size: 10MB # 로그인 슬라이드·ReRoom 참조 이미지 업로드 상한(사진 시안, 소유자 지시 13.2) + max-request-size: 12MB datasource: url: ${DB_URL:jdbc:postgresql://localhost:5432/kintex_db} username: ${DB_USER:kintex} diff --git a/src/frontend/src/screens/design/BoothDesignStudioPage.tsx b/src/frontend/src/screens/design/BoothDesignStudioPage.tsx index 82c593b..aaa62c3 100644 --- a/src/frontend/src/screens/design/BoothDesignStudioPage.tsx +++ b/src/frontend/src/screens/design/BoothDesignStudioPage.tsx @@ -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 { ApiRequestError } from '../../api/client'; import { designApi, renderApi } from '../../api/endpoints'; @@ -9,6 +9,12 @@ import { AiImage } from '../../components/ui/AiImage'; import { CompareSlider } from '../../components/ui/CompareSlider'; import type { ComplianceReport, DesignSpec, RenderJobDto, RenderJobRequest } from '../../api/types'; 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'; /* @@ -65,6 +71,18 @@ export function BoothDesignStudioPage() { const [confirmAgree, setConfirmAgree] = useState(false); const [notice, setNotice] = useState(null); + // ── 사진 시안(ReRoom, image-to-image) — 소유자 지시 13.2 ── + const fileInputRef = useRef(null); + const [refPreview, setRefPreview] = useState(null); // 로컬 objectURL(Before) + const [refId, setRefId] = useState(null); // 서버 참조 이미지 id + const [refUploading, setRefUploading] = useState(false); + const [reroomStyle, setReroomStyle] = useState('modern'); + const [reroomInstruction, setReroomInstruction] = useState(''); + const [reroomJob, setReroomJob] = useState(null); + const [reroomBusy, setReroomBusy] = useState(false); + const [reroomError, setReroomError] = useState(null); + const [dragOver, setDragOver] = useState(false); + // 초기 설계안 로드(501이면 degraded 기본 스펙 유지). useEffect(() => { let alive = true; @@ -93,6 +111,91 @@ export function BoothDesignStudioPage() { return () => unsubs.forEach((u) => u()); }, [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 () => { setPrechecking(true); setNotice(null); @@ -424,6 +527,158 @@ export function BoothDesignStudioPage() { + {/* 사진 시안(AI) — ReRoomAI 방식 image-to-image (소유자 지시 13.2) */} +

+
+ 사진 시안 (AI) +

+ 빈 부스·공간 사진을 올리면 골격·구도는 그대로 두고 스타일만 바꾼 “시공 후” 예상 사진을 생성합니다. + 시공 기준은 도면이며, 생성 이미지는 참고용입니다. +

+
+ +
+ {/* 01 업로드 */} +
+ 01 빈 부스 사진 +
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 ? ( + 참조 이미지 미리보기 + ) : ( + + + 사진을 끌어다 놓거나 클릭해 업로드 + JPG · PNG · WebP · 10MB 이하 + + )} + {refUploading && ( + + 업로드 중… + + )} +
+ void handleRefFile(e.target.files?.[0])} + /> +
+ + {/* 02 스타일 + 03 지시 + 생성 */} +
+ 02 스타일 +
+ {REROOM_STYLES.map((s) => ( + + ))} +
+ + 03 추가 지시 (선택) +