const MODEL_HEIGHT_CM = 168; const CATEGORIES = ["상의", "하의", "스커트", "원피스", "아우터"]; const FITS = ["슬림", "표준", "오버"]; const LENGTH_MAP = { 상의: ["허리", "골반", "허벅지"], 아우터: ["허리", "골반", "허벅지"], 하의: ["허벅지", "무릎", "종아리", "발목", "신발"], 스커트: ["허벅지", "무릎", "종아리", "발목", "신발"], 원피스: ["허벅지", "무릎", "종아리", "발목", "신발"], }; const BODY_RATIOS = { 신발: 0.0, 발목: 0.05, 종아리: 0.17, 무릎: 0.25, 허벅지: 0.36, 골반: 0.48, 허리: 0.57 }; const START_RATIOS = { 상의: 0.82, 아우터: 0.82, 원피스: 0.82, 하의: 0.57, 스커트: 0.57 }; function calcTotalLength(height, category, length) { const start = START_RATIOS[category]; const end = BODY_RATIOS[length]; return start != null && end != null && height ? Math.round((start - end) * height * 10) / 10 : 0; } const selectableGroups = [ document.querySelectorAll(".segmented button"), document.querySelectorAll('.tag-group[data-group="category"] button'), document.querySelectorAll('.tag-group[data-group="length"] button'), document.querySelectorAll('.tag-group[data-group="fit"] button'), document.querySelectorAll(".face-chip"), ]; const DEFAULT_PRODUCT_OPTIONS = { category: "아우터", length: "골반", fit: "표준", }; const productOptionStates = Array.from({ length: 4 }, () => ({ ...DEFAULT_PRODUCT_OPTIONS })); // Shot buttons depend on the selected garment category. const SHOT_TYPES_TOP = [ "전신(앞면)", "전신(측면)", "전신(후면)", "상반신(앞면)", "상반신(측면)", "상반신(후면)", "상반신(클로즈업)", "디테일(후면)", ]; const SHOT_TYPES_BOTTOM = [ "전신(앞면)", "전신(측면)", "전신(후면)", "상반신(앞면)", "상반신(측면)", "하반신", "하반신(클로즈업)", ]; const SHOT_TYPES_BY_CATEGORY = { 상의: SHOT_TYPES_TOP, 아우터: SHOT_TYPES_TOP, 원피스: SHOT_TYPES_TOP, 하의: SHOT_TYPES_BOTTOM, 스커트: SHOT_TYPES_BOTTOM, }; const getShotTypesForCategory = (category) => SHOT_TYPES_BY_CATEGORY[category] || SHOT_TYPES_TOP; // Free-pose library removed — each shot now maps 1:1 to a named reference on the server. const POSE_REFERENCES = []; const appState = { selectedBaseIndex: null, hasFrontCandidates: false, hasPrunedFrontCandidates: false, activeShotType: "", selectedShotTypes: new Set(), selectedPoseReference: null, modalOriginalSrc: "", modalCurrentSrc: "", modalTitle: "", modalCardUid: "", }; let resultCardUid = 0; const getSelectedCategory = () => { const active = document.querySelector('.tag-group[data-group="shot-target"] button.active'); return active ? active.textContent.trim() : "상의"; }; const ensureShotButtonUi = () => { const shotGrid = document.querySelector("#shotGrid"); if (!shotGrid) { return; } // Remove the legacy free-pose reference block if it exists. document.querySelector(".pose-reference-block")?.remove(); shotGrid.innerHTML = ""; getShotTypesForCategory(getSelectedCategory()).forEach((shotType) => { const button = document.createElement("button"); button.type = "button"; button.dataset.shot = shotType; button.textContent = shotType; button.disabled = true; shotGrid.append(button); }); }; ensureShotButtonUi(); selectableGroups.forEach((buttons) => { buttons.forEach((button) => { button.addEventListener("click", () => { buttons.forEach((item) => item.classList.remove("active")); button.classList.add("active"); if (button.closest('[data-group="category"]')) { syncLengthOptions(button.textContent.trim()); saveCurrentProductOptions(); } if (button.closest('[data-group="length"]')) { syncLengthSummary(); saveCurrentProductOptions(); } if (button.closest('[data-group="fit"]')) { saveCurrentProductOptions(); } }); }); }); const enableShotButtons = () => { document.querySelectorAll("#shotGrid button, .pose-reference-card").forEach((button) => { button.disabled = !appState.hasFrontCandidates || appState.selectedBaseIndex == null; }); syncBatchShotButton(); }; const getPoseReferenceBlob = async () => { const reference = appState.selectedPoseReference; if (!reference?.src) { return null; } try { if (reference.src.startsWith("data:image/")) { return dataUrlToBlob(reference.src); } const response = await fetch(reference.src); if (!response.ok) { return null; } return response.blob(); } catch (error) { console.warn("포즈 레퍼런스 이미지를 불러오지 못했습니다.", error); return null; } }; const selectPoseReference = (button) => { if (button.disabled) { return; } document.querySelectorAll(".pose-reference-card").forEach((item) => item.classList.remove("active")); document.querySelectorAll("#shotGrid button").forEach((item) => item.classList.remove("active")); button.classList.add("active"); appState.selectedPoseReference = { shotType: button.dataset.shot || "전신(자유포즈)", title: button.dataset.title || "선택 포즈", src: button.dataset.src || "", }; appState.selectedShotTypes = new Set([appState.selectedPoseReference.shotType]); syncBatchShotButton(); }; const getPoseResultLabel = (shotType) => { const title = appState.selectedPoseReference?.title; if (!title) { return shotType; } const base = shotType.replace(/\(.+\)/, ""); return `${base}(${title})`; }; const renderPoseReferenceList = () => { const list = document.querySelector("#poseReferenceList"); if (!list) { return; } list.innerHTML = ""; const grouped = POSE_REFERENCES.reduce((items, reference) => { items[reference.group] = [...(items[reference.group] || []), reference]; return items; }, {}); Object.entries(grouped).forEach(([group, references]) => { const section = document.createElement("section"); section.className = "pose-reference-section"; section.innerHTML = `

${group}

`; const row = document.createElement("div"); row.className = "pose-reference-row"; references.forEach((reference) => { const button = document.createElement("button"); button.className = "pose-reference-card"; button.type = "button"; button.disabled = !appState.hasFrontCandidates || appState.selectedBaseIndex == null; button.dataset.shot = reference.shotType; button.dataset.title = reference.title; button.dataset.src = reference.src; button.innerHTML = ` ${reference.title} 포즈 레퍼런스 ${reference.title} `; button.querySelector("img").addEventListener("error", (event) => { event.currentTarget.hidden = true; }); button.addEventListener("click", () => selectPoseReference(button)); row.append(button); }); section.append(row); list.append(section); }); }; renderPoseReferenceList(); const uploadPreview = document.querySelector("#uploadPreview"); const productInputs = document.querySelectorAll(".product-slot input"); const modelFaceUpload = document.querySelector("#modelFaceUpload"); const useUploadedFace = document.querySelector("#useUploadedFace"); const presetFaceButton = document.querySelector("#presetFaceButton"); modelFaceUpload.addEventListener("change", () => { const [file] = modelFaceUpload.files; const label = modelFaceUpload.closest(".face-upload"); if (file) { const preview = URL.createObjectURL(file); label.querySelectorAll("img").forEach((image) => image.remove()); label.querySelector("span").style.display = "none"; label.querySelector("em").style.display = "none"; label.insertAdjacentHTML("beforeend", `?낅줈?쒗븳 紐⑤뜽 ?쇨뎬`); useUploadedFace.checked = true; presetFaceButton.classList.remove("active"); } }); presetFaceButton.addEventListener("click", () => { useUploadedFace.checked = false; presetFaceButton.classList.add("active"); }); productInputs.forEach((input) => { input.addEventListener("change", () => { const [file] = input.files; const slot = input.closest(".product-slot"); uploadPreview.querySelectorAll(".product-slot").forEach((item) => item.classList.remove("active")); slot.classList.add("active"); if (file) { const label = slot.querySelector("em")?.textContent || input.name; const preview = URL.createObjectURL(file); slot.classList.add("has-image"); slot.querySelectorAll("img").forEach((image) => image.remove()); const uploadIcon = slot.querySelector(".upload-icon"); if (uploadIcon) { uploadIcon.hidden = true; } slot.insertAdjacentHTML("afterbegin", `${label} ?낅줈???대?吏€`); } }); }); document.querySelector("#poseReferenceUpload")?.addEventListener("change", () => { const [file] = document.querySelector("#poseReferenceUpload").files; if (!file) { return; } const reader = new FileReader(); reader.addEventListener("load", () => { const list = document.querySelector("#poseReferenceList"); if (!list) { return; } const section = document.createElement("section"); section.className = "pose-reference-section custom-pose-section"; section.innerHTML = "

직접 업로드

"; const row = document.createElement("div"); row.className = "pose-reference-row"; const button = document.createElement("button"); button.className = "pose-reference-card"; button.type = "button"; button.disabled = !appState.hasFrontCandidates || appState.selectedBaseIndex == null; button.dataset.shot = "전신(자유포즈)"; button.dataset.title = file.name.replace(/\.[^.]+$/, "") || "업로드 포즈"; button.dataset.src = reader.result; button.innerHTML = ` 업로드 포즈 레퍼런스 ${button.dataset.title} `; button.addEventListener("click", () => selectPoseReference(button)); row.append(button); section.append(row); list.prepend(section); selectPoseReference(button); }); reader.readAsDataURL(file); }); const getActiveText = (selector) => { const active = document.querySelector(`${selector} .active`); return active ? active.textContent.trim() : ""; }; const getActiveProductIndex = () => { const buttons = [...document.querySelectorAll('[data-group="selected-product"] button')]; const index = buttons.findIndex((button) => button.classList.contains("active")); return Math.max(0, index); }; const setActiveByText = (selector, value) => { const buttons = [...document.querySelectorAll(`${selector} button`)]; const target = buttons.find((button) => button.textContent.trim() === value) || buttons[0]; buttons.forEach((button) => button.classList.toggle("active", button === target)); return target?.textContent.trim() || ""; }; const lengthSelect = document.querySelector("#length"); const totalLength = document.querySelector("#totalLength"); const syncLengthSummary = () => { const category = getActiveText('[data-group="category"]') || "아우터"; const length = getActiveText('[data-group="length"]') || "골반"; const calculated = calcTotalLength(MODEL_HEIGHT_CM, category, length); totalLength.textContent = calculated ? `${calculated}cm` : "-"; }; const saveCurrentProductOptions = () => { const index = getActiveProductIndex(); productOptionStates[index] = { category: getActiveText('[data-group="category"]') || DEFAULT_PRODUCT_OPTIONS.category, length: getActiveText('[data-group="length"]') || DEFAULT_PRODUCT_OPTIONS.length, fit: getActiveText('[data-group="fit"]') || DEFAULT_PRODUCT_OPTIONS.fit, }; }; const syncLengthOptions = (category, preferredLength = "") => { const options = LENGTH_MAP[category] || []; const lengthTags = document.querySelector("#lengthTags"); const fallbackIndex = Math.min(1, options.length - 1); const activeLength = options.includes(preferredLength) ? preferredLength : options[fallbackIndex]; lengthTags.innerHTML = ""; options.forEach((length) => { const button = document.createElement("button"); button.type = "button"; button.textContent = length; button.classList.toggle("active", length === activeLength); button.addEventListener("click", () => { lengthTags.querySelectorAll("button").forEach((item) => item.classList.remove("active")); button.classList.add("active"); syncLengthSummary(); saveCurrentProductOptions(); }); lengthTags.append(button); }); syncLengthSummary(); }; syncLengthOptions(getActiveText('[data-group="category"]') || "아우터"); saveCurrentProductOptions(); const applyProductOptions = (index) => { const state = productOptionStates[index] || DEFAULT_PRODUCT_OPTIONS; const category = setActiveByText('[data-group="category"]', state.category); syncLengthOptions(category || state.category, state.length); setActiveByText('[data-group="fit"]', state.fit); syncLengthSummary(); }; document.querySelectorAll('[data-group="selected-product"] button').forEach((button, index) => { button.addEventListener("click", () => { saveCurrentProductOptions(); document.querySelectorAll('[data-group="selected-product"] button').forEach((item) => item.classList.remove("active")); button.classList.add("active"); applyProductOptions(index); }); }); const buildGenerateForm = async ({ mode = "front_candidates", shotType = "", shotTypes = [] } = {}) => { const formData = new FormData(); const isFrontMode = mode === "front_candidates" || mode === "front_candidate"; const activeProductButtons = [...document.querySelectorAll('[data-group="selected-product"] button')]; const activeProductIndex = Math.max(0, activeProductButtons.findIndex((button) => button.classList.contains("active"))); const inputsToUpload = isFrontMode ? [...productInputs] : [...productInputs].filter((input) => input.name.startsWith(`product_${activeProductIndex + 1}_`)); inputsToUpload.forEach((input) => { const [file] = input.files; if (file) { formData.append(input.name, file); } }); const [modelFace] = modelFaceUpload.files; if (modelFace && useUploadedFace.checked) { formData.append("model_face", modelFace); formData.append("face_source", "업로드 얼굴"); } else { formData.append("face_source", "첨부 얼굴 프리셋"); } formData.append("selected_product", getActiveText('[data-group="selected-product"]') || "제품 1"); formData.append("category", getActiveText('[data-group="category"]') || "아우터"); formData.append("fit", getActiveText('[data-group="fit"]') || "표준"); formData.append("length", getActiveText('[data-group="length"]') || "골반"); formData.append("style", "커머스 룩북"); formData.append("image_model", document.querySelector("#imageModel").value); formData.append("prompt", document.querySelector("#prompt").value); formData.append("pose", "전신(정면)"); formData.append("resolution", getActiveText('[data-group="resolution"]') || "1K"); formData.append("total_length_cm", totalLength.textContent.replace("cm", "")); formData.append("generation_mode", mode); formData.append("shot_type", shotType); formData.append("shot_types", shotTypes.join("|")); formData.append("selected_base_index", String(appState.selectedBaseIndex ?? 0)); formData.append("only_selected_cut", "false"); if (!isFrontMode) { const selectedCard = getSelectedBaseCard(); const selectedImage = selectedCard?.querySelector(".generated-image"); const selectedReference = selectedCard?.dataset.referenceSrc || selectedImage?.src || ""; if (selectedReference.startsWith("data:image/")) { formData.append("selected_reference_image", await dataUrlToBlob(selectedReference), "selected-reference.png"); } // Pose/framing reference is resolved on the server per shot type (assets/poses/