Spaces:
Sleeping
Sleeping
| 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(); | |