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}
`;
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", `
`);
}
});
});
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/