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:
parent
ba34abaafe
commit
0f91dcf4ae
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
94
src/frontend/src/screens/design/reroomApi.ts
Normal file
94
src/frontend/src/screens/design/reroomApi.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 생성형 오버레이 (발표/설명용 보조 — 시공 검증 아님)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
24
tools/nanobanana/prompts/reroom_photo.txt
Normal file
24
tools/nanobanana/prompts/reroom_photo.txt
Normal 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.
|
||||||
32
tools/nanobanana/prompts/reroom_styles.json
Normal file
32
tools/nanobanana/prompts/reroom_styles.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user