test_ROEM / script.js
sunyoung00's picture
Upload 2 files
d92add3 verified
Raw
History Blame Contribute Delete
50.3 kB
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 = `<h3>${group}</h3>`;
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 = `
<span class="pose-thumb">
<img src="${reference.src}" alt="${reference.title} 포즈 레퍼런스" />
</span>
<em>${reference.title}</em>
`;
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", `<img src="${preview}" alt="?낅줈?쒗븳 紐⑤뜽 ?쇨뎬" />`);
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", `<img src="${preview}" alt="${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 = "<h3>직접 업로드</h3>";
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 = `
<span class="pose-thumb"><img src="${reader.result}" alt="업로드 포즈 레퍼런스" /></span>
<em>${button.dataset.title}</em>
`;
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/<label>).
}
return formData;
};
const selectResultCard = (card) => {
document.querySelectorAll(".result-card.selected").forEach((item) => item.classList.remove("selected"));
card.classList.add("selected");
appState.selectedBaseIndex = Number(card.dataset.index || 0);
enableShotButtons();
};
const getSelectedBaseCard = () => {
const selectedCards = [...document.querySelectorAll(".result-card.selected")];
if (selectedCards.length <= 1) {
return selectedCards[0] || null;
}
const latestSelected = selectedCards[selectedCards.length - 1];
selectedCards.forEach((card) => {
if (card !== latestSelected) {
card.classList.remove("selected");
}
});
appState.selectedBaseIndex = Number(latestSelected.dataset.index || 0);
return latestSelected;
};
const keepOnlySelectedBaseCard = () => {
if (appState.hasPrunedFrontCandidates) {
return;
}
const selectedCard = getSelectedBaseCard();
if (!selectedCard) {
return;
}
[...document.querySelectorAll(".result-card")].forEach((card) => {
if (card !== selectedCard) {
card.remove();
}
});
selectedCard.dataset.index = "0";
appState.selectedBaseIndex = 0;
appState.hasPrunedFrontCandidates = true;
};
const toggleDownloadSelection = (card) => {
const isSelected = card.classList.toggle("download-selected");
const button = card.querySelector(".select-download-button");
if (button) {
button.textContent = isSelected ? "선택됨" : "선택";
button.setAttribute("aria-pressed", String(isSelected));
}
};
const renderGeneratedImages = (images, { append = false, labels = [], elapsed = "", activateFirst = false } = {}) => {
if (!append) {
resultGrid.innerHTML = "";
}
images.forEach((src, index) => {
const cardIndex = append ? resultGrid.querySelectorAll(".result-card").length : index;
const card = document.createElement("article");
card.className = `result-card${!append && index === 0 ? " selected" : ""}`;
card.dataset.index = String(cardIndex);
card.dataset.referenceSrc = src;
card.dataset.cleanSrc = src;
card.dataset.aiLabel = "false";
card.dataset.uid = `result-${resultCardUid}`;
resultCardUid += 1;
const label = labels[index] || `${cardIndex + 1}번 모델컷`;
card.innerHTML = `
<img class="generated-image" src="${src}" alt="생성된 AI 모델컷 ${index + 1}" />
<div class="card-toolbar">
<span>${label}${elapsed ? ` · ${elapsed}` : ""}</span>
<button class="select-download-button" type="button" aria-pressed="false">선택</button>
</div>
`;
card.addEventListener("click", () => selectResultCard(card));
card.querySelector(".select-download-button").addEventListener("click", (event) => {
event.stopPropagation();
toggleDownloadSelection(card);
});
card.querySelector(".generated-image").addEventListener("click", (event) => {
event.stopPropagation();
const image = card.querySelector(".generated-image");
openImageModal(image.src, label, card);
});
resultGrid.append(card);
if (append && activateFirst && !document.querySelector(".result-card.selected")) {
appState.hasFrontCandidates = true;
appState.hasPrunedFrontCandidates = false;
selectResultCard(card);
}
});
if (!append && images.length) {
const firstCard = resultGrid.querySelector(".result-card");
appState.selectedBaseIndex = 0;
appState.hasFrontCandidates = true;
appState.hasPrunedFrontCandidates = false;
if (firstCard) {
selectResultCard(firstCard);
}
}
};
const renderError = (error) => {
resultGrid.innerHTML = "";
const card = document.createElement("article");
card.className = "result-card error-card";
card.innerHTML = `
<div class="error-body">
<strong>?앹꽦 ?ㅽ뙣</strong>
<span>${error.message || "?????녿뒗 ?ㅻ쪟"}</span>
${error.provider ? `<em>provider: ${error.provider}</em>` : ""}
${error.model ? `<em>model: ${error.model}</em>` : ""}
${error.resolution ? `<em>resolution: ${error.resolution}</em>` : ""}
</div>
`;
resultGrid.append(card);
};
const generateButton = document.querySelector("#generateButton");
const batchShotButton = document.querySelector("#batchShotButton");
const resultGrid = document.querySelector("#resultGrid");
const elapsedTime = document.querySelector("#elapsedTime");
const elapsedDetail = document.querySelector("#elapsedDetail");
const imageModal = document.querySelector("#imageModal");
const modalImage = document.querySelector("#modalImage");
const modalTitle = document.querySelector("#modalTitle");
let generationTimer = null;
const openImageModal = (src, title, card = null) => {
modalImage.src = src;
modalTitle.textContent = title;
appState.modalOriginalSrc = card?.dataset.cleanSrc || src;
appState.modalCurrentSrc = src;
appState.modalTitle = title;
appState.modalCardUid = card?.dataset.uid || "";
imageModal.classList.add("open");
imageModal.setAttribute("aria-hidden", "false");
};
const closeImageModal = () => {
imageModal.classList.remove("open");
imageModal.setAttribute("aria-hidden", "true");
modalImage.removeAttribute("src");
};
const dataUrlToBlob = async (dataUrl) => {
const response = await fetch(dataUrl);
return response.blob();
};
const downloadDataUrl = (dataUrl, filename) => {
const link = document.createElement("a");
link.href = dataUrl;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
};
const convertImageFormat = (src, format) =>
new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0);
const mime = {
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp",
}[format];
resolve(canvas.toDataURL(mime, format === "jpg" ? 0.94 : 0.9));
};
image.onerror = reject;
image.src = src;
});
const makeCrcTable = () => {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
};
const crcTable = makeCrcTable();
const crc32 = (bytes) => {
let crc = 0xffffffff;
for (const byte of bytes) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
};
const dataUrlToBytes = (dataUrl) => {
const base64 = dataUrl.split(",")[1] || "";
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
};
const writeUint16 = (array, offset, value) => {
array[offset] = value & 0xff;
array[offset + 1] = (value >>> 8) & 0xff;
};
const writeUint32 = (array, offset, value) => {
array[offset] = value & 0xff;
array[offset + 1] = (value >>> 8) & 0xff;
array[offset + 2] = (value >>> 16) & 0xff;
array[offset + 3] = (value >>> 24) & 0xff;
};
const createZipBlob = (files) => {
const encoder = new TextEncoder();
const localParts = [];
const centralParts = [];
let offset = 0;
files.forEach((file) => {
const nameBytes = encoder.encode(file.name);
const fileBytes = file.bytes;
const checksum = crc32(fileBytes);
const localHeader = new Uint8Array(30 + nameBytes.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, fileBytes.length);
writeUint32(localHeader, 22, fileBytes.length);
writeUint16(localHeader, 26, nameBytes.length);
localHeader.set(nameBytes, 30);
const centralHeader = new Uint8Array(46 + nameBytes.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, fileBytes.length);
writeUint32(centralHeader, 24, fileBytes.length);
writeUint16(centralHeader, 28, nameBytes.length);
writeUint32(centralHeader, 42, offset);
centralHeader.set(nameBytes, 46);
localParts.push(localHeader, fileBytes);
centralParts.push(centralHeader);
offset += localHeader.length + fileBytes.length;
});
const centralSize = centralParts.reduce((total, part) => total + part.length, 0);
const endRecord = new Uint8Array(22);
writeUint32(endRecord, 0, 0x06054b50);
writeUint16(endRecord, 8, files.length);
writeUint16(endRecord, 10, files.length);
writeUint32(endRecord, 12, centralSize);
writeUint32(endRecord, 16, offset);
return new Blob([...localParts, ...centralParts, endRecord], { type: "application/zip" });
};
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const downloadSelectedImagesAsZip = async (format) => {
const images = [...document.querySelectorAll(".result-card.download-selected .generated-image")];
if (!images.length) {
alert("다운로드할 컷을 먼저 선택하세요.");
return;
}
downloadMenuButton.disabled = true;
downloadMenuButton.textContent = "ZIP 생성 중";
try {
const files = [];
for (const [index, image] of images.entries()) {
const dataUrl = await convertImageFormat(image.src, format);
files.push({
name: `modelcut-${String(index + 1).padStart(2, "0")}.${format}`,
bytes: dataUrlToBytes(dataUrl),
});
}
downloadBlob(createZipBlob(files), `modelcut-selected-${format}.zip`);
} catch (error) {
alert(error.message || "ZIP 다운로드 생성에 실패했습니다.");
} finally {
downloadMenuButton.disabled = false;
downloadMenuButton.textContent = "선택 다운로드";
}
};
const applyAiLabelToImage = (src) =>
new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d");
context.drawImage(image, 0, 0);
const fontSize = Math.max(32, Math.round(canvas.width * 0.036));
const padding = Math.round(canvas.width * 0.065);
context.font = `300 ${fontSize}px Pretendard_Light, Pretendard, Arial, sans-serif`;
context.textAlign = "right";
context.textBaseline = "top";
context.fillStyle = "#3c3c3c";
context.fillText("AI 생성 이미지", canvas.width - padding, padding);
resolve(canvas.toDataURL("image/png"));
};
image.onerror = reject;
image.src = src;
});
const syncModalImageFromCard = (card) => {
if (!card || appState.modalCardUid !== card.dataset.uid) {
return;
}
const image = card.querySelector(".generated-image");
if (image) {
appState.modalCurrentSrc = image.src;
appState.modalOriginalSrc = card.dataset.cleanSrc || image.src;
modalImage.src = image.src;
}
};
const toggleAiLabelForCard = async (card) => {
const image = card.querySelector(".generated-image");
const cleanSrc = card.dataset.cleanSrc || image?.src || "";
if (!image || !cleanSrc) {
return;
}
if (card.dataset.aiLabel === "true") {
card.dataset.aiLabel = "false";
image.src = cleanSrc;
syncModalImageFromCard(card);
return;
}
const labeledImage = await applyAiLabelToImage(cleanSrc);
card.dataset.aiLabel = "true";
image.src = labeledImage;
syncModalImageFromCard(card);
};
const applyAiLabelToSelected = async () => {
const downloadSelectedCards = [...document.querySelectorAll(".result-card.download-selected")];
const baseSelectedCard = document.querySelector(".result-card.selected");
const modalSourceCard = appState.modalCardUid
? [...document.querySelectorAll(".result-card")].find((card) => card.dataset.uid === appState.modalCardUid)
: appState.modalCurrentSrc
? [...document.querySelectorAll(".result-card")].find((card) => {
const image = card.querySelector(".generated-image");
return image?.src === appState.modalCurrentSrc || card.dataset.cleanSrc === appState.modalCurrentSrc;
})
: null;
const targetCards = downloadSelectedCards.length
? downloadSelectedCards
: [modalSourceCard || baseSelectedCard].filter(Boolean);
if (!targetCards.length) {
alert("AI ?쒓린瑜??곸슜??而룹쓣 癒쇱? ?좏깮?섏꽭??");
return;
}
for (const card of targetCards) {
await toggleAiLabelForCard(card);
}
};
const applyAiLabelToModal = async () => {
if (!appState.modalCurrentSrc) {
alert("AI ?쒓린瑜?誘몃━ 蹂?而룹쓣 癒쇱? ?댁뼱二쇱꽭??");
return;
}
const sourceCard = appState.modalCardUid
? [...document.querySelectorAll(".result-card")].find((card) => card.dataset.uid === appState.modalCardUid)
: null;
if (sourceCard) {
await toggleAiLabelForCard(sourceCard);
return;
}
const cleanSrc = appState.modalOriginalSrc || appState.modalCurrentSrc;
const isLabeled = appState.modalCurrentSrc !== cleanSrc;
appState.modalCurrentSrc = isLabeled ? cleanSrc : await applyAiLabelToImage(cleanSrc);
modalImage.src = appState.modalCurrentSrc;
};
document.querySelectorAll("[data-close-modal]").forEach((button) => {
button.addEventListener("click", closeImageModal);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && imageModal.classList.contains("open")) {
closeImageModal();
}
});
const downloadMenu = document.querySelector(".download-menu");
const downloadMenuButton = document.querySelector("#downloadMenuButton");
const downloadOptions = document.querySelector("#downloadOptions");
const applyAiLabelButton = document.querySelector("#applyAiLabelButton");
const modalAiLabelButton = document.querySelector("#modalAiLabelButton");
applyAiLabelButton.addEventListener("click", applyAiLabelToSelected);
modalAiLabelButton.addEventListener("click", applyAiLabelToModal);
downloadMenuButton.addEventListener("click", (event) => {
event.stopPropagation();
downloadMenu.classList.toggle("open");
});
downloadOptions.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", async () => {
downloadMenu.classList.remove("open");
await downloadSelectedImagesAsZip(button.dataset.format);
});
});
document.addEventListener("click", (event) => {
if (!downloadMenu.contains(event.target)) {
downloadMenu.classList.remove("open");
}
});
const editReferenceImages = document.querySelector("#editReferenceImages");
const editReferenceList = document.querySelector("#editReferenceList");
const editInstruction = document.querySelector("#editInstruction");
const requestEditButton = document.querySelector("#requestEditButton");
const regenerateButton = document.querySelector("#regenerateButton");
const saveOriginalButton = document.querySelector("#saveOriginalButton");
const saveCurrentButton = document.querySelector("#saveCurrentButton");
let selectedBackground = "";
editReferenceImages.addEventListener("change", () => {
editReferenceList.innerHTML = "";
[...editReferenceImages.files].forEach((file) => {
const preview = URL.createObjectURL(file);
editReferenceList.insertAdjacentHTML("beforeend", `<img src="${preview}" alt="李몄“ ?대?吏€" />`);
});
});
document.querySelectorAll("#editBackgrounds button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("#editBackgrounds button").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
selectedBackground = button.dataset.bg || "";
});
});
const requestEdit = async (instructionOverride = "") => {
if (!appState.modalCurrentSrc) {
return;
}
requestEditButton.textContent = "?섏젙 以?..";
requestEditButton.disabled = true;
const formData = new FormData();
formData.append("base_image", await dataUrlToBlob(appState.modalCurrentSrc), "selected.png");
formData.append("image_model", document.querySelector("#imageModel").value);
formData.append("resolution", getActiveText('[data-group="resolution"]') || "1K");
formData.append("background", selectedBackground);
formData.append("instruction", instructionOverride || editInstruction.value);
[...editReferenceImages.files].forEach((file) => {
formData.append("reference_images", file);
});
try {
const response = await fetch("/api/edit", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || "?섏젙 ?붿껌 ?ㅽ뙣");
}
const [editedImage] = data.images || [];
if (editedImage) {
appState.modalCurrentSrc = editedImage;
modalImage.src = editedImage;
renderGeneratedImages([editedImage], {
append: true,
labels: [data.labels?.[0] || "?섏젙 ?대?吏€"],
});
}
} catch (error) {
alert(error.message || "?섏젙 ?붿껌 ?ㅽ뙣");
} finally {
requestEditButton.textContent = "?섏젙 ?붿껌?섍린";
requestEditButton.disabled = false;
}
};
requestEditButton.addEventListener("click", () => requestEdit());
regenerateButton.addEventListener("click", () => requestEdit("湲곗〈 ?ㅼ젙???좎??섍퀬 ?대?吏€瑜???踰????먯뿰?ㅻ읇寃??ъ깮?깊븯?몄슂."));
const getModalSaveSource = async () => {
const sourceCard = appState.modalCardUid
? [...document.querySelectorAll(".result-card")].find((card) => card.dataset.uid === appState.modalCardUid)
: null;
const sourceImage = sourceCard?.querySelector(".generated-image");
if (sourceCard?.dataset.aiLabel === "true") {
const currentSrc = sourceImage?.src || modalImage.src || appState.modalCurrentSrc;
const cleanSrc = sourceCard.dataset.cleanSrc || appState.modalOriginalSrc || currentSrc;
const labeledSrc = currentSrc && currentSrc !== cleanSrc ? currentSrc : await applyAiLabelToImage(cleanSrc);
appState.modalCurrentSrc = labeledSrc;
modalImage.src = labeledSrc;
if (sourceImage) {
sourceImage.src = labeledSrc;
}
return labeledSrc;
}
return modalImage.src || sourceImage?.src || appState.modalCurrentSrc || appState.modalOriginalSrc;
};
saveOriginalButton.addEventListener("click", async () => {
const source = await getModalSaveSource();
if (source) {
downloadDataUrl(source, `${appState.modalTitle || "original"}.png`);
}
});
saveCurrentButton.addEventListener("click", async () => {
const source = await getModalSaveSource();
if (source) {
downloadDataUrl(source, `${appState.modalTitle || "edited"}-current.png`);
}
});
const formatElapsed = (milliseconds) => {
const totalSeconds = Math.max(1, Math.round(milliseconds / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}${seconds}초`;
}
return `${seconds}초`;
};
const updateEstimatedTime = () => {
elapsedTime.textContent = "대기 중";
elapsedDetail.textContent = "생성 시작 후 실시간으로 표시됩니다.";
};
const syncBatchShotButton = () => {
const hasPoseReference = Boolean(appState.selectedPoseReference);
batchShotButton.disabled = !appState.hasFrontCandidates || appState.selectedBaseIndex == null || (!hasPoseReference && appState.selectedShotTypes.size === 0);
batchShotButton.textContent = hasPoseReference
? `${appState.selectedPoseReference.title} 포즈로 변경`
: appState.selectedShotTypes.size
? `선택 샷 ${appState.selectedShotTypes.size}개 생성`
: "샷을 선택하면 생성할 수 있어요";
};
const requestGeneration = async ({ mode, shotType = "", shotTypes = [], append = false, loadingText = "새 컷 생성 중" }) => {
const startedAt = performance.now();
generateButton.textContent = "생성 중...";
generateButton.disabled = true;
elapsedTime.textContent = "생성 중 · 0초";
elapsedDetail.textContent = "실시간 경과 시간을 측정 중입니다.";
window.clearInterval(generationTimer);
generationTimer = window.setInterval(() => {
elapsedTime.textContent = `생성 중 · ${formatElapsed(performance.now() - startedAt)}`;
}, 1000);
const loadingCard = document.createElement("article");
loadingCard.className = "result-card loading";
loadingCard.innerHTML = `
<div class="image-stage queue"></div>
<div class="card-toolbar">
<span>${loadingText}</span>
<button type="button">以묒?</button>
</div>
`;
if (!append) {
resultGrid.innerHTML = "";
}
resultGrid.prepend(loadingCard);
try {
const response = await fetch("/api/generate", {
method: "POST",
body: await buildGenerateForm({ mode, shotType, shotTypes }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const error = new Error(errorData.error || "?앹꽦 ?붿껌 ?ㅽ뙣");
Object.assign(error, errorData);
throw error;
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const elapsed = formatElapsed(performance.now() - startedAt);
window.clearInterval(generationTimer);
elapsedTime.textContent = elapsed;
elapsedDetail.textContent = `${mode === "front_candidates" ? "?꾩떊(?뺣㈃) ?꾨낫" : shotType} ?앹꽦 ?꾨즺`;
loadingCard.remove();
renderGeneratedImages(data.images || [], { append, labels: data.labels || [], elapsed });
} catch (error) {
window.clearInterval(generationTimer);
elapsedTime.textContent = "?앹꽦 ?ㅽ뙣";
elapsedDetail.textContent = "?ㅻ쪟 ?댁슜???뺤씤?섏꽭??";
renderError(error);
console.error(error);
} finally {
generateButton.textContent = "?꾩떊(?뺣㈃) 3???앹꽦";
generateButton.disabled = false;
}
};
const requestFrontCandidate = async (candidateIndex, startedAt) => {
const loadingCard = document.createElement("article");
loadingCard.className = "result-card loading";
loadingCard.innerHTML = `
<div class="image-stage queue"></div>
<div class="card-toolbar">
<span>전신(정면) 후보 ${candidateIndex + 1} 생성 중</span>
<button type="button">대기</button>
</div>
`;
resultGrid.prepend(loadingCard);
try {
const response = await fetch("/api/generate", {
method: "POST",
body: await buildGenerateForm({ mode: "front_candidate" }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const error = new Error(errorData.error || `전신(정면) 후보 ${candidateIndex + 1} 생성 요청 실패`);
Object.assign(error, errorData);
throw error;
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const elapsed = formatElapsed(performance.now() - startedAt);
loadingCard.remove();
renderGeneratedImages(data.images || [], {
append: true,
labels: [`전신(정면) 후보 ${candidateIndex + 1}`],
elapsed,
activateFirst: true,
});
return { ok: true };
} catch (error) {
loadingCard.remove();
renderShotErrorCard(`전신(정면) 후보 ${candidateIndex + 1}`, error);
console.error(error);
return { ok: false, error };
}
};
const requestFrontCandidatesIndividually = async () => {
const startedAt = performance.now();
generateButton.textContent = "생성 중...";
generateButton.disabled = true;
batchShotButton.disabled = true;
resultGrid.innerHTML = "";
elapsedTime.textContent = "생성 중 · 0초";
elapsedDetail.textContent = "전신(정면) 후보 3장을 각각 생성 중입니다.";
window.clearInterval(generationTimer);
generationTimer = window.setInterval(() => {
elapsedTime.textContent = `생성 중 · ${formatElapsed(performance.now() - startedAt)}`;
}, 1000);
const results = await Promise.allSettled([0, 1, 2].map((index) => requestFrontCandidate(index, startedAt)));
const completed = results.filter((result) => result.status === "fulfilled" && result.value?.ok).length;
const elapsed = formatElapsed(performance.now() - startedAt);
window.clearInterval(generationTimer);
elapsedTime.textContent = elapsed;
elapsedDetail.textContent = `전신(정면) 후보 ${completed}/3장 생성 완료`;
generateButton.textContent = "전신(정면) 3장 생성";
generateButton.disabled = false;
if (completed > 0) {
appState.hasFrontCandidates = true;
enableShotButtons();
}
syncBatchShotButton();
};
const renderShotErrorCard = (shotType, error) => {
const card = document.createElement("article");
card.className = "result-card error-card";
card.innerHTML = `
<div class="error-body">
<strong>${shotType} 생성 실패</strong>
<span>${error.message || "알 수 없는 오류"}</span>
</div>
`;
resultGrid.prepend(card);
};
const createQueuedShotCard = (displayLabel) => {
const card = document.createElement("article");
card.className = "result-card loading queued";
card.innerHTML = `
<div class="image-stage queue"></div>
<div class="card-toolbar">
<span>${displayLabel} 대기 중</span>
<button type="button">대기</button>
</div>
`;
return card;
};
const setShotCardGenerating = (card, displayLabel) => {
card.classList.remove("queued");
const span = card.querySelector(".card-toolbar span");
const badge = card.querySelector(".card-toolbar button");
if (span) span.textContent = `${displayLabel} 생성 중`;
if (badge) badge.textContent = "생성 중";
};
const fillShotResultCard = (card, src, label, elapsed) => {
const cardIndex = Math.max(0, [...resultGrid.querySelectorAll(".result-card")].indexOf(card));
card.className = "result-card";
card.dataset.index = String(cardIndex);
card.dataset.referenceSrc = src;
card.dataset.cleanSrc = src;
card.dataset.aiLabel = "false";
card.dataset.uid = `result-${resultCardUid}`;
resultCardUid += 1;
card.innerHTML = `
<img class="generated-image" src="${src}" alt="${label}" />
<div class="card-toolbar">
<span>${label}${elapsed ? ` · ${elapsed}` : ""}</span>
<button class="select-download-button" type="button" aria-pressed="false">선택</button>
</div>
`;
card.addEventListener("click", () => selectResultCard(card));
card.querySelector(".select-download-button").addEventListener("click", (event) => {
event.stopPropagation();
toggleDownloadSelection(card);
});
card.querySelector(".generated-image").addEventListener("click", (event) => {
event.stopPropagation();
openImageModal(card.querySelector(".generated-image").src, label, card);
});
};
const showShotErrorInCard = (card, shotType, error) => {
card.className = "result-card error-card";
card.innerHTML = `
<div class="error-body">
<strong>${shotType} 생성 실패</strong>
<span>${error.message || "알 수 없는 오류"}</span>
</div>
`;
};
const requestShotVariant = async (shotType, batchStartedAt, card) => {
const displayLabel = getPoseResultLabel(shotType);
setShotCardGenerating(card, displayLabel);
try {
const response = await fetch("/api/generate", {
method: "POST",
body: await buildGenerateForm({ mode: "shot_variant", shotType }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const error = new Error(errorData.error || `${shotType} 생성 요청 실패`);
Object.assign(error, errorData);
throw error;
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const src = (data.images || [])[0];
if (!src) {
throw new Error(`${shotType} 이미지를 받지 못했습니다.`);
}
const elapsed = formatElapsed(performance.now() - batchStartedAt);
fillShotResultCard(card, src, displayLabel, elapsed);
return { shotType, ok: true };
} catch (error) {
showShotErrorInCard(card, shotType, error);
console.error(error);
return { shotType, ok: false, error };
}
};
const requestShotVariantsIndividually = async (shotTypes) => {
keepOnlySelectedBaseCard();
const startedAt = performance.now();
generateButton.disabled = true;
batchShotButton.disabled = true;
batchShotButton.textContent = "선택 샷 생성 중";
elapsedTime.textContent = "생성 중 · 0초";
elapsedDetail.textContent = `선택한 샷 ${shotTypes.length}개를 순서대로 생성하는 중입니다.`;
window.clearInterval(generationTimer);
generationTimer = window.setInterval(() => {
elapsedTime.textContent = `생성 중 · ${formatElapsed(performance.now() - startedAt)}`;
}, 1000);
// 선택한 샷 전부를 "대기" 카드로 먼저 깔아둔다 (선택 순서 유지, 베이스 컷 앞쪽에 배치).
const fragment = document.createDocumentFragment();
const queue = shotTypes.map((shotType) => {
const card = createQueuedShotCard(getPoseResultLabel(shotType));
fragment.append(card);
return { shotType, card };
});
resultGrid.prepend(fragment);
const results = [];
const maxConcurrent = 3;
for (let index = 0; index < queue.length; index += maxConcurrent) {
const group = queue.slice(index, index + maxConcurrent);
const groupResults = await Promise.allSettled(
group.map(({ shotType, card }) => requestShotVariant(shotType, startedAt, card)),
);
results.push(...groupResults);
}
const completed = results.filter((result) => result.status === "fulfilled" && result.value?.ok).length;
const elapsed = formatElapsed(performance.now() - startedAt);
window.clearInterval(generationTimer);
elapsedTime.textContent = elapsed;
elapsedDetail.textContent = `선택 샷 ${completed}/${shotTypes.length}개 생성 완료`;
generateButton.disabled = false;
syncBatchShotButton();
};
generateButton.addEventListener("click", async () => {
appState.selectedBaseIndex = null;
appState.hasFrontCandidates = false;
appState.hasPrunedFrontCandidates = false;
appState.activeShotType = "";
appState.selectedShotTypes.clear();
appState.selectedPoseReference = null;
document.querySelectorAll("#shotGrid button").forEach((button) => button.classList.remove("active"));
document.querySelectorAll(".pose-reference-card").forEach((button) => button.classList.remove("active"));
enableShotButtons();
await requestFrontCandidatesIndividually();
});
document.querySelector("#shotGrid")?.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button || button.disabled || !document.querySelector("#shotGrid").contains(button)) {
return;
}
const shotType = button.dataset.shot;
if (!shotType) {
return;
}
appState.selectedPoseReference = null;
button.classList.toggle("active");
if (button.classList.contains("active")) {
appState.selectedShotTypes.add(shotType);
} else {
appState.selectedShotTypes.delete(shotType);
}
syncBatchShotButton();
});
// 샷 구성의 상의/하의 토글 → 해당 샷 버튼으로 재렌더
document.querySelectorAll('.tag-group[data-group="shot-target"] button').forEach((button) => {
button.addEventListener("click", () => {
document
.querySelectorAll('.tag-group[data-group="shot-target"] button')
.forEach((item) => item.classList.remove("active"));
button.classList.add("active");
appState.selectedShotTypes.clear();
ensureShotButtonUi();
enableShotButtons();
syncBatchShotButton();
});
});
batchShotButton.addEventListener("click", async () => {
if (batchShotButton.disabled) {
return;
}
const shotTypes = appState.selectedPoseReference
? [appState.selectedPoseReference.shotType]
: [...appState.selectedShotTypes];
await requestShotVariantsIndividually(shotTypes);
});
enableShotButtons();
renderPoseReferenceList();
updateEstimatedTime();