/* ============================================================
   app-typed.jsx — 상품배너 550 · 유형별 자동배치 (개선 시안 v2)
   좌측 인스펙터 = 필수 (유형 → 카테고리(자동) → 단일 이미지 업로드[상품/모델 자동 분류] → ▸고급)
   우측 인스펙터 = 선택 (로고 + 택1: 부가텍스트/부가이미지/인증플래그/이미지표기)
   단일 업로드 영역: 각 이미지를 비전으로 판별 → 인물=모델(우측 밴드) / 그 외=상품(좌측 클러스터).
   A 누끼형은 엔진(window.AP550)+캔버스 재사용. B~E 셸.
   ============================================================ */
/* [de-iframe 격리] 이 파일 전체를 IIFE로 감싼다 — 전역 이름(BANNER_TYPES·useS2 등)이
   메인 앱(merged/*)과 충돌하지 않도록. 밖으로는 파일 끝에서 window.AutoPlace550 하나만 노출.
   canvas.jsx 컴포넌트(APResultCard 등)는 전역이라 IIFE 안에서 그대로 읽힌다. */
(function () {
const { useState: useS2, useRef: useSR2, useEffect: useSE2, useMemo: useSM2 } = React;

function drawAdjusted(ctx, image, adjustments, ...args) {
  if (window.BannerImageTools && window.BannerImageTools.drawImage) {
    return window.BannerImageTools.drawImage(ctx, image, adjustments, ...args);
  }
  return ctx.drawImage(image, ...args);
}

const BANNER_TYPES = [
{ id: 'A', label: '누끼형', sub: '배경 제거 · 클러스터 배치', ready: true },
{ id: 'B', label: '분할+누끼형', sub: '분할 패널 + 누끼', ready: true },
{ id: 'C', label: '분할형', sub: '2~4분할 풀사진', ready: true },
{ id: 'D', label: '풀이미지형', sub: '한 장 풀블리드', ready: true },
{ id: 'E', label: '보험', sub: '로고 + 보험명(+모델)', ready: true }];

// 가이드(예: 'B 분할+누끼형' / 'C 분할형(3분할)' / 'D 풀이미지형' / '보험')에서 배너 유형 id 추출.
// merged가 AutoPlace550에 guide를 넘기면(또는 window.__ap550Guide) 그 유형으로 에디터가 열린다. 없으면 A.
function guideToTypeId(g) {
  if (!g) return null;
  const s = String((g && (g.name || g.title || g.label)) || g || '');
  if (/보험/.test(s)) return 'E';
  if (/분할\s*\+\s*누끼|분할\+누끼/.test(s)) return 'B';
  if (/풀이미지/.test(s)) return 'D';
  if (/분할형|분할\s*\(/.test(s) || /분할/.test(s) && !/누끼/.test(s)) return 'C';
  if (/누끼/.test(s)) return 'A';
  return null;
}

// 결과 미리보기 크기 — A 누끼형(canvas.jsx APResultCard의 APStage dispW=364)과 동일하게 통일.
// 레이아웃 세부는 추후 전 유형 일괄 정비 예정 → 여기선 A와 같은 크기만 맞춘다.
const RESULT_DISP = 364;
const LOGO_550_MAX_COUNT = 3;
const LOGO_550_MAX_ROW = 225;
const LOGO_550_GAP = 10;
const LOGO_550_RIGHT = 500; // 550 - 우측여백 50
const LOGO_550_MID = 275;   // 절반선 — 로고 행 왼쪽 끝이 넘지 못함
function logoOrient550(g) {
  if (g && (g.orient === 'h' || g.orient === 'v')) return g.orient;
  return logoAspect550(g && g.aspect) >= 1.6 ? 'h' : 'v';
}
function logoScale550(value) {
  const n = Number(value);
  return Number.isFinite(n) && n > 0 ? Math.max(0.25, Math.min(4, n)) : 1;
}
function logoScaleW550(g) {
  const n = Number(g && g.scaleW);
  if (Number.isFinite(n) && n > 0) return Math.max(0.25, Math.min(4, n));
  return logoScale550(g && g.scale);
}
function logoScaleH550(g) {
  const n = Number(g && g.scaleH);
  if (Number.isFinite(n) && n > 0) return Math.max(0.25, Math.min(4, n));
  return logoScale550(g && g.scale);
}
function logoRow550(logos, heightFor, maxRow) {
  if (!heightFor && window.LogoPlacement550) {
    return window.LogoPlacement550.layout(logos, { maxRow });
  }
  const usePerLogoScale = !heightFor;
  const arr = (logos || []).slice(0, LOGO_550_MAX_COUNT).map((g) => {
    const aspect = logoAspect550(g.aspect);
    const orient = logoOrient550(g);
    const baseH = heightFor ? heightFor({ ...g, orient, aspect }) : (orient === 'h' ? 20 : 60);
    const baseW = baseH * aspect;
    return { url: g.url, baseH, baseW, orient, aspect };
  });
  if (!arr.length) return { items: [], rowH: 0, gap: LOGO_550_GAP, rowW: 0, k: 1, minX: LOGO_550_RIGHT };
  const totalW = arr.reduce((s, l) => s + l.baseW, 0) + LOGO_550_GAP * (arr.length - 1);
  const limit = Number.isFinite(Number(maxRow)) ? Number(maxRow) : LOGO_550_MAX_ROW;
  // 하드 기하 클램프: 폭 225 초과 또는 minX < 275 이면 행 전체를 균일 축소
  const cap = Math.min(limit, LOGO_550_RIGHT - LOGO_550_MID);
  let k = totalW > cap ? cap / totalW : 1;
  let rowW = totalW * k;
  let minX = LOGO_550_RIGHT - rowW;
  if (minX < LOGO_550_MID && totalW > 0) {
    k = (LOGO_550_RIGHT - LOGO_550_MID) / totalW;
    rowW = totalW * k;
    minX = LOGO_550_RIGHT - rowW;
  }
  const gap = LOGO_550_GAP * k;
  const rightEdges = new Array(arr.length);
  let xR = LOGO_550_RIGHT;
  for (let i = arr.length - 1; i >= 0; i--) {
    rightEdges[i] = xR;
    xR = xR - arr[i].baseW * k - gap;
  }
  const logoList = (logos || []).slice(0, LOGO_550_MAX_COUNT);
  const items = arr.map((l, i) => {
    const bw = l.baseW * k, bh = l.baseH * k;
    let w = bw, h = bh;
    if (usePerLogoScale) {
      const g = logoList[i] || {};
      w = bw * logoScaleW550(g);
      h = bh * logoScaleH550(g);
      const maxW = rightEdges[i] - LOGO_550_MID;
      if (w > maxW && maxW > 0) w = maxW;
      if (w < 1) w = 1;
      if (h > 510) h = 510;
      if (h < 1) h = 1;
    }
    return { url: l.url, w, h, baseW: bw, baseH: bh, rightEdge: rightEdges[i] };
  });
  return {
    items,
    rowH: Math.max.apply(null, items.map((l) => l.h)),
    gap,
    rowW,
    k,
    minX,
  };
}

// cj-catalog taxonomy.path[0] 기준 14개 GNB 루트 (SSOT: commerce-category.js GNB_ROOTS 동기).
const COMMERCE_CATS = [
  '여성패션', '남성패션', '언더웨어', '패션잡화', '스포츠/레저',
  '뷰티', '식품', '주방용품', '출산/유아동',
  '가구/인테리어', '생활용품', '가전',
  '렌탈/여행', 'TV상품'
];
const PROJECT_CATEGORY_LABELS = new Set(['상품배너 550', '상품배너 H1', '앱푸시', '1E 기술서상단']);
function restoredCommerceCategory(raw) {
  const c = raw == null ? '' : String(raw).trim();
  return c && !PROJECT_CATEGORY_LABELS.has(c) ? c : '';
}
// 7버킷 기반 엔진 룰 — GNB 루트→버킷 변환(ROOT_TO_COMMERCE)을 거쳐 catRule()로 조회.
const CAT_RULES = {
  '패션': { prodMax: 4, modelMax: 4, modelCentric: true },
  '패션잡화': { prodMax: 8, modelMax: 1 }, '뷰티': { prodMax: 10, modelMax: 1 },
  '식품/주방': { prodMax: 10, modelMax: 1 }, '가구/가전/생활': { prodMax: 10, modelMax: 1 },
  '유아동/도서': { prodMax: 10, modelMax: 1 }, '여행/보험': { prodMax: 2, modelMax: 1, logo: true }
};
const DEFAULT_RULE = { prodMax: 10, modelMax: 1 };
// GNB 루트(14개) → 7버킷 룰 조회. TV상품 등 매핑 없는 루트는 DEFAULT_RULE 반환.
function catRule(gnbRoot) {
  const r2c = (window.__commerceCategory && window.__commerceCategory.ROOT_TO_COMMERCE) || {};
  const bucket = r2c[gnbRoot] || gnbRoot;
  return CAT_RULES[bucket] || DEFAULT_RULE;
}
// VISION_TO_COMMERCE 라벨 → 대표 GNB 루트(자동감지 폴백용).
const BUCKET_TO_GNB = { '패션': '여성패션', '식품/주방': '식품', '가구/가전/생활': '가전', '유아동/도서': '출산/유아동', '여행/보험': '렌탈/여행', '뷰티': '뷰티', '패션잡화': '패션잡화' };
const VISION_TO_COMMERCE = {
  '의류': '패션', '패션잡화': '패션잡화', '화장품': '뷰티', '의약품': '뷰티', '건강기능식품': '뷰티',
  '식품': '식품/주방', '음료': '식품/주방', '생활용품': '가구/가전/생활', '가전': '가구/가전/생활'
};
const TCAT_LABELS = ['식품', '음료', '의약품', '건강기능식품', '화장품', '의류', '패션잡화', '가전', '생활용품', '모델', '기타'];
const TCAT_NORM = { '음식': '식품', '과자': '식품', '차': '음료', 'tea': '음료', '커피': '음료', '생수': '음료', '술': '음료', '쉐이크': '음료', '두유': '음료', '코스메틱': '화장품', '뷰티': '화장품', '전자제품': '가전', '사람': '모델', '인물': '모델', '패션': '의류' };
const TCAT_PROMPT = '제품/모델 이미지다. 먼저 "이미지에 사람(인물)의 얼굴·신체가 보이면 모델"로 본다. 사람이 없으면 브랜드·제품명·문구·섭취/사용 방식으로 상거래 대분류를 정한다. 첫 줄에 "카테고리: <단어>" 형식. 보기: ' + TCAT_LABELS.join(', ') + '. 옷을 사람이 입고 있으면 "모델", 옷만 있으면 "의류". 신발·부츠·운동화·가방·지갑·악세서리는 사람 없이 상품만 있으면 "패션잡화"(모델 아님). 차·커피·주스·생수·쉐이크 등 마시는 것은 "음료", 과자·식사·식재료는 "식품".';
function isCatalogFoodKitchen(record) {
  if (!record) return false;
  const path = Array.isArray(record.categoryPath) ? record.categoryPath.join('>') : '';
  const text = [record.categoryRoot, record.category, record.categoryLeaf, record.label, record.sourceCategoryPath, record.name, path]
    .filter(Boolean).join(' ');
  return /식품|주방|쌀|밥|곡물|과일|복숭아|사과|수박|토마토|조리|냄비|팬|식기/i.test(text);
}
const _li2 = (s) => new Promise((r, j) => {const i = new Image();i.crossOrigin = 'anonymous';i.onload = () => r(i);i.onerror = j;i.src = s;});
async function urlToB64_2(src, ms) {const im = await _li2(src);const sc = Math.min(1, (ms || 512) / Math.max(im.width, im.height));const c = document.createElement('canvas');c.width = Math.round(im.width * sc);c.height = Math.round(im.height * sc);const x = c.getContext('2d');x.fillStyle = '#fff';x.fillRect(0, 0, c.width, c.height);x.drawImage(im, 0, 0, c.width, c.height);return c.toDataURL('image/jpeg', 0.9).split(',')[1];}
async function classifyVision(src, record) {
  if (isCatalogFoodKitchen(record)) return null;
  const hasMp = !!window.__mpClassify;
  // 로컬(MediaPipe)로 상품/모델 먼저 판별 — 사람이면 '모델'(LLM 불필요). 1E 자동배치와 동일한 파이프라인.
  let mpLabel = null;
  if (hasMp) { try { mpLabel = await window.__mpClassify(src); if (mpLabel === '모델') return '모델'; } catch (e) { mpLabel = null; } }
  // 어시스턴트·카탈로그 메타(categoryRoot/label) 있으면 상품 TCAT LLM 스킵 — item.category는 applyToItem에서 시드됨.
  const cm = typeof window !== 'undefined' && window.__catalogMeta;
  const cc = typeof window !== 'undefined' && window.__commerceCategory;
  if (cm && cc && cm.hasCatalogCategory(record, cc)) return null;
  // 상품이면 상품 카테고리는 LLM 분류로 (있을 때만) — LLM 코드 유지·삭제 안 함
  if (!(window.__bannerLLM && window.__bannerLLM.available())) return null;
  const b64 = await urlToB64_2(src, 512);
  const r = await window.__bannerLLM.complete({ messages: [{ role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: b64 } }, { type: 'text', text: TCAT_PROMPT }] }] });
  const s = String(r || '');const m = s.match(/카테고리\s*[:：]\s*([^\s,\n.]+)/);
  let label = (m ? m[1] : s.trim().split(/[\s,\n/·]/)[0]).replace(/["'`.]/g, '');
  label = TCAT_NORM[label] || label;
  // HARD RULE: 로컬 인물 감지가 없으면 LLM의 '모델' 응답은 제품 오판으로 본다.
  // 옷/신발/가방 단독 상품컷이 우측 모델 밴드로 빠지는 문제를 막는다.
  if (mpLabel && mpLabel !== '모델' && label === '모델') return null;
  return TCAT_LABELS.includes(label) ? label : label || null;
}
const isModelLabel = (l) => l === '모델';
async function analyzeModelHead(src) {
  if (window.__mpHead) { try { const r = await window.__mpHead(src); if (r) return r; } catch (e) {} }
  // 알파/흰배경 기반 — 실제 콘텐츠(머리) 상단과 머리 폭 검출 (비전 불필요, 오프라인 동작 · MediaPipe 실패 시 폴백)
  const img = await new Promise((res, rej) => {const i = new Image();i.crossOrigin = 'anonymous';i.onload = () => res(i);i.onerror = rej;i.src = src;});
  const W = 120,H = Math.max(1, Math.round(120 * img.height / Math.max(1, img.width)));
  const c = document.createElement('canvas');c.width = W;c.height = H;
  const x = c.getContext('2d');x.drawImage(img, 0, 0, W, H);
  const d = x.getImageData(0, 0, W, H).data;
  const isBg = (r, g, b, a) => a < 128 || r > 244 && g > 244 && b > 244;
  let top = -1,bot = -1;const rowW = new Array(H).fill(0);
  for (let y = 0; y < H; y++) {let l = W,r = -1;for (let xx = 0; xx < W; xx++) {const i = (y * W + xx) * 4;if (!isBg(d[i], d[i + 1], d[i + 2], d[i + 3])) {if (xx < l) l = xx;if (xx > r) r = xx;}}if (r >= l) {if (top < 0) top = y;bot = y;rowW[y] = r - l + 1;}}
  if (top < 0) return null;
  const contentH = bot - top + 1;
  const bandEnd = Math.min(bot, top + Math.max(2, Math.round(contentH * 0.14)));
  const band = [];for (let y = top; y <= bandEnd; y++) if (rowW[y]) band.push(rowW[y]);
  band.sort((a, b) => a - b);const headW = band.length ? band[Math.floor(band.length / 2)] : 1;
  let maxUW = 0;const upEnd = Math.min(bot, top + Math.round(contentH * 0.55)); // 상체 최대 폭(어깨) — 머리카락 폭에 둔감
  for (let y = top; y <= upEnd; y++) if (rowW[y] > maxUW) maxUW = rowW[y];
  let shoulder = -1;const scanFrom = top + Math.max(2, Math.round(contentH * 0.12)); // 머리 구간 건너뛰고 어깨끝 검출
  for (let y = scanFrom; y <= bot; y++) {if (rowW[y] >= maxUW * 0.82) {shoulder = y;break;}}
  if (shoulder < 0) shoulder = Math.min(bot, top + Math.round(contentH * 0.42));
  return { topFrac: top / H, hsFrac: Math.max(0.12, Math.min(0.45, (shoulder - top) / H)) }; // 머리끝~어깨끝 (머리끝·턱끝·어깨끝 정렬 기준)
}
async function detectFaceMeta(src) {
  if (!(window.__bannerLLM && window.__bannerLLM.available())) return null;
  const b64 = await urlToB64_2(src, 384);
  const r = await window.__bannerLLM.complete({ messages: [{ role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: b64 } }, { type: 'text', text: '인물 이미지다. (1) 정수리부터 턱끝까지(얼굴 세로 길이)가 이미지 전체 높이의 몇 %인지 정수 faceH, (2) 식별 특징(머리색·헤어스타일) 한 단어 person. 형식 그대로: faceH=17, person=긴갈발' }] }] });
  const s = String(r || '');
  const fh = s.match(/faceH\s*=\s*([0-9.]+)/i),p = s.match(/person\s*=\s*([^\s,\n]+)/i);
  const faceH = fh ? parseFloat(fh[1]) : null;
  return { faceHFrac: faceH && faceH > 2 && faceH < 60 ? faceH / 100 : null, person: p ? p[1].replace(/["'`.]/g, '') : null };
}

function TSpinner({ size = 16 }) {return <span className="ap-spin" style={{ width: size, height: size }} />;}
function MiniSlider({ label, value, min, max, step, fmt, onChange, hint }) {
  const pct = (value - min) / (max - min) * 100;
  return (
    <div style={{ marginTop: '14px' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', fontSize: '12.5px', color: '#43434e', marginBottom: '8px' }}><span>{label}</span><b style={{ color: '#1c1c22', fontSize: '13px' }}>{fmt(value)}</b></div>
      <input type="range" className="ap-mini-range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(parseFloat(e.target.value))} style={{ width: '100%', background: `linear-gradient(90deg, #8b7cf0, #6d5ef0 ${pct}%, #e9e9ef ${pct}%)` }} />
      {hint && <div style={{ fontSize: '11px', color: '#9a9aa6', marginTop: '6px' }}>{hint}</div>}
    </div>);

}
const EXCLUSIVE = [
{ id: 'text', label: '부가 텍스트', hint: '1+1·SET·개수 등 · 최대 3줄/8자' },
{ id: 'image', label: '부가 이미지', hint: '사은품·상품평·상품디테일 · 1개' },
{ id: 'flag', label: '인증 플래그', hint: '디지털 상품 전용 · 최대 2개' },
{ id: 'mark', label: '이미지 표기', hint: '상품 사이 · 다수구성/OR/구성품 · 최대 3' },
{ id: 'color', label: '컬러칩', hint: '대표 색상 팔레트 · 헥사 입력/이미지 추출' }];

const NL = String.fromCharCode(10);
const TEXT_PRESETS = ['set', '브랜드' + NL + '단독', '1+1', '×30', '310x140', '52주분', '문구 2줄', '문구 3줄'];
const FLAG_DEFAULT_TEXT = '인증' + NL + '플래그';
const makeFlagItem = () => ({ mode: 'text', text: FLAG_DEFAULT_TEXT, src: null });
function normalizeFlagItems(items) {
  const src = Array.isArray(items) ? items : [];
  return [0, 1].map((i) => {
    const item = src[i] || {};
    return {
      mode: item.mode === 'image' ? 'image' : 'text',
      text: item.text != null ? String(item.text) : FLAG_DEFAULT_TEXT,
      src: item.src || null,
    };
  });
}
function flagTextLines(raw) {
  const lines = String(raw || FLAG_DEFAULT_TEXT).split(String.fromCharCode(13)).join('').split(NL).map((x) => x.trim()).filter(Boolean).slice(0, 2);
  return lines.length ? lines : FLAG_DEFAULT_TEXT.split(NL);
}
const textVisibleLen = (s) => String(s || '').split('').filter((ch) => ch !== ' ' && ch !== NL).length;
function parseBadge(raw) {
  const s = String(raw || '').split(String.fromCharCode(13)).join('').trim();
  if (!s) return { kind: 'empty' };
  if (s.indexOf(NL) >= 0) {const lines = s.split(NL).map((x) => x.trim()).filter(Boolean).slice(0, 3);return { kind: 'lines', lines };}
  let m = s.match(/^[x×X][ ]*([0-9]{1,4})$/);
  if (m) return { kind: 'times', num: m[1] };
  m = s.match(/^([0-9]{1,4})[ ]*([가-힣]{1,3})$/);
  if (m) return { kind: 'numUnit', num: m[1], unit: m[2] };
  m = s.match(/^([0-9]{1,4})[ ]*[xX×][ ]*([0-9]{1,4})$/);
  if (m) return { kind: 'dim', a: m[1], b: m[2] };
  if (s.indexOf(' ') >= 0) {const parts = s.split(/[ ]+/).filter(Boolean);const lines = parts.length > 3 ? [parts[0], parts[1], parts.slice(2).join(' ')] : parts;return { kind: 'lines', lines };}
  const plus = s.indexOf('+');
  if (plus > 0 && plus < s.length - 1 && s.length > 4) return { kind: 'lines', lines: [s.slice(0, plus + 1), s.slice(plus + 1)] };
  if (/[가-힣]/.test(s) && s.length > 4) {const n = s.length > 8 ? 3 : 2;const per = Math.ceil(s.length / n);const arr = [];for (let i = 0; i < s.length; i += per) arr.push(s.slice(i, i + per));return { kind: 'lines', lines: arr.slice(0, 3) };}
  return { kind: 'single', text: s };
}
function localFormatText(s) {s = String(s || '').trim();if (!s) return s;if (s.indexOf(' ') >= 0) return s.split(/[ ]+/).filter(Boolean).slice(0, 3).join(NL);return s;}
function AddTextBadge({ text, size = 130 }) {
  const k = size / 90;
  const p = parseBadge(text);
  const INNER = 73.8;
  const fitW = (len, cap) => Math.min(cap, INNER / (Math.max(1, len) * 0.6));
  const fitH = (n, cap) => Math.min(cap, 75 / (n * 1.18));
  const T = (fs, extra) => ({ color: '#fff', fontWeight: 500, fontSize: fs * k + 'px', lineHeight: 1, letterSpacing: '-0.02em', whiteSpace: 'nowrap', fontFamily: '"Noto Sans KR", "Noto Sans CJK KR", sans-serif', display: 'inline-block', transform: 'translateY(-0.055em)', ...extra });
  const wrap = (inner) => <div style={{ width: size + 'px', height: size + 'px', borderRadius: '50%', background: 'rgba(104,10,186,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', boxSizing: 'border-box', overflow: 'hidden' }}>{inner}</div>;
  if (p.kind === 'empty') return wrap(<span style={{ color: 'rgba(255,255,255,.55)', fontSize: 14 * k + 'px', fontWeight: 600 }}>미리보기</span>);
  // ×30 — 숫자 Medium 40/-5%, × 기호 Regular 30/0% (피그마 스펙)
  if (p.kind === 'times') {const nf = Math.min(40, fitW(String(p.num).length + 1.1, 40));return wrap(<span style={{ display: 'inline-flex', alignItems: 'baseline' }}><span style={T(nf * 0.75, { fontWeight: 400, letterSpacing: '0' })}>×</span><span style={T(nf, { letterSpacing: '-0.05em' })}>{p.num}</span></span>);}
  // 52주분 — 숫자 Medium 36/-5%, 단위 Regular 20/-2.5% (피그마 스펙)
  if (p.kind === 'numUnit') {const nf = Math.min(36, (INNER - p.unit.length * 20 * 0.55) / (String(p.num).length * 0.55));return wrap(<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 1 * k + 'px' }}><span style={T(nf, { letterSpacing: '-0.05em' })}>{p.num}</span><span style={T(20, { fontWeight: 400, letterSpacing: '-0.025em' })}>{p.unit}</span></span>);}
  // 310x140 — 전부 Medium 20, 숫자 -7.5%, x 0% (피그마 스펙)
  if (p.kind === 'dim') {const fs = Math.min(20, INNER / ((p.a.length + p.b.length) * 0.55 + 0.6));return wrap(<span style={{ display: 'inline-flex', alignItems: 'baseline' }}><span style={T(fs, { letterSpacing: '-0.075em' })}>{p.a}</span><span style={T(fs, { letterSpacing: '0' })}>x</span><span style={T(fs, { letterSpacing: '-0.075em' })}>{p.b}</span></span>);}
  // set/1+1 등 단일 — Medium 36/-2% (피그마 스펙)
  if (p.kind === 'single') {const fs = fitW(p.text.length, 36);return wrap(<span style={T(fs)}>{p.text}</span>);}
  // 다줄: 브랜드형(2글자 이하·CJ단독) Medium, 본문형(문구 2/3줄) Regular (피그마 스펙)
  const lines = p.lines;const maxLen = Math.max.apply(null, lines.map((l) => l.length).concat(1));
  const brand = lines.length < 3 && maxLen <= 2;
  const base = lines.length >= 3 ? 18.5 : brand ? 36 : 20;
  const fs = Math.min(base, fitW(maxLen, base), fitH(lines.length, base));
  const lw = brand ? 500 : 400;
  const lls = brand ? '-0.02em' : '-0.025em';
  const llh = lines.length >= 3 ? 1.24 : 1.2;
  return wrap(<span style={{ display: 'flex', flexDirection: 'column' }}>{lines.map((l, i) => <span key={i} style={T(fs, { fontWeight: lw, letterSpacing: lls, lineHeight: llh })}>{l}</span>)}</span>);
}
window.AddTextBadge = AddTextBadge; // 캔버스(canvas.jsx)에서 부가텍스트 배지를 그대로 렌더하기 위해 전역 노출

const MARK_TYPES = [{ id: '다수구성', label: '다수 구성 +' }, { id: 'OR', label: 'OR' }, { id: '구성품', label: '구성품' }];
const ADD_IMG_TYPES = [
{ id: '사은품', label: '사은품', icon: true },
{ id: '상품평', label: '상품평', icon: true },
{ id: '상품디테일', label: '상품디테일', icon: false }];

const CHIP_MAX = 8;
const clampPct = (v) => Math.max(0, Math.min(100, v));
const clampAddImgZoom = (v) => {
  const n = Number(v);
  return Number.isFinite(n) && n > 0 ? Math.max(0.5, Math.min(3, n)) : 1;
};
const normalizeRestoredAddImgPos = (pos) => {
  const p = pos && typeof pos === 'object' ? pos : {};
  const zoom = clampAddImgZoom(p.zoom != null ? p.zoom : 1);
  return {
    x: clampPct(p.x != null ? Number(p.x) || 50 : 50),
    y: clampPct(p.y != null ? Number(p.y) || 50 : 50),
    zoom: zoom > 1 ? 1 : zoom,
  };
};
const logoAspect550 = (value) => {
  const n = Number(value);
  return Number.isFinite(n) && n > 0 ? Math.max(0.25, Math.min(20, n)) : 1;
};
async function detectImageKind(src) {
  // 누끼(컷) vs 화보(사진) 판별. 기준 = "실제 투명(알파)이 있으면 누끼, 불투명이면 화보".
  //  · JPEG은 알파 채널 자체가 불가 → 무조건 화보(빠른 경로).
  //  · PNG여도 알파 없이 꽉 찬 이미지(예: ChatGPT 생성 화보 = RGB PNG)는 투명 0% → 화보.
  //    배경제거 누끼는 투명 여백이 있음(샘플 0.8~80%). → 포맷이 아니라 "실제 투명비율"로 판정.
  //  ⚠ 투명 없이 저장된 누끼(알파 없는 RGB PNG)는 화보와 픽셀상 구분 불가 → 화보로 처리(수동 배지로 전환).
  try {
    if (/^data:image\/jpe?g/i.test(src) || /\.jpe?g(?:[?#]|$)/i.test(src)) return 'photo';  // JPEG → 화보(알파 불가)
  } catch (e) {}
  // 투명픽셀 비율로 판정: 스무딩 꺼서 얇은 투명 테두리 보존, 임계 0.3%(불투명 화보 0% ↔ 누끼 ≥0.8% 사이).
  try {
    const img = await new Promise((res, rej) => {const i = new Image();i.crossOrigin = 'anonymous';i.onload = () => res(i);i.onerror = rej;i.src = src;});
    const iw = img.naturalWidth || img.width || 1, ih = img.naturalHeight || img.height || 1;
    const W = Math.min(256, iw),H = Math.max(1, Math.round(W * ih / Math.max(1, iw)));
    const c = document.createElement('canvas');c.width = W;c.height = H;
    const x = c.getContext('2d');x.imageSmoothingEnabled = false;x.drawImage(img, 0, 0, W, H);
    const d = x.getImageData(0, 0, W, H).data;
    let transparent = 0;const total = W * H;
    for (let i = 3; i < d.length; i += 4) if (d[i] < 200) transparent++;
    return transparent / total > 0.003 ? 'nukki' : 'photo';
  } catch (e) {return 'photo';}
}
// [SPLIT-FIX] contentBboxCrop: 흰 배경을 제거한 콘텐츠 bbox를 잘라 dataURL 반환.
//   - 흰 배경 비율 ≥ 20%: bbox 외 흰색을 제거하고 자른 이미지(JPEG dataURL)와 bbox 비율(aspect)를 반환.
//   - 흰 배경 비율 < 20%: 컬러/환경 배경 사진 → 원본 src와 이미지 비율 그대로 반환(isCropped:false).
//   C 분할형에서 패널 크기를 "콘텐츠" 기준으로 산정하기 위해 splitPhotos 생성 시 호출.
//   반환: { url, aspect, isCropped }. 실패 시 원본 src·이미지 비율 폴백.
async function contentBboxCrop(src) {
  try {
    const img = await new Promise((res, rej) => {const i = new Image();i.crossOrigin = 'anonymous';i.onload = () => res(i);i.onerror = rej;i.src = src;});
    const NW = img.naturalWidth || 1, NH = img.naturalHeight || 1;
    const S = Math.min(256, NW);
    const SH = Math.max(1, Math.round(S * NH / NW));
    const c = document.createElement('canvas');c.width = S;c.height = SH;
    const ctx = c.getContext('2d');
    ctx.fillStyle = '#fff';ctx.fillRect(0, 0, S, SH);
    ctx.drawImage(img, 0, 0, S, SH);
    const d = ctx.getImageData(0, 0, S, SH).data;
    // 흰 배경 비율 — R/G/B > 230 (근접 흰색)
    let white = 0;
    for (let i = 0; i < d.length; i += 4) {if (d[i] > 230 && d[i + 1] > 230 && d[i + 2] > 230) white++;}
    if (white / (S * SH) < 0.2) return { url: src, aspect: NW / NH, isCropped: false }; // 컬러 배경 → 원본
    // 콘텐츠(비흰색) bbox 검출
    let x0 = S, y0 = SH, x1 = -1, y1 = -1;
    for (let y = 0; y < SH; y++) for (let x = 0; x < S; x++) {
      const p = (y * S + x) * 4;
      if (!(d[p] > 230 && d[p + 1] > 230 && d[p + 2] > 230)) {
        if (x < x0) x0 = x;if (x > x1) x1 = x;if (y < y0) y0 = y;if (y > y1) y1 = y;
      }
    }
    if (x1 < 0) return { url: src, aspect: NW / NH, isCropped: false }; // 전부 흰색
    // 소형 여백(2%) 추가 → 원본 좌표로 역환산
    const MG = 0.02;
    const sx = Math.max(0, Math.round((x0 / S - MG) * NW));
    const sy = Math.max(0, Math.round((y0 / SH - MG) * NH));
    const sw = Math.min(NW - sx, Math.round((x1 - x0 + 1) / S * NW + 2 * MG * NW));
    const sh = Math.min(NH - sy, Math.round((y1 - y0 + 1) / SH * NH + 2 * MG * NH));
    if (sw < 4 || sh < 4) return { url: src, aspect: NW / NH, isCropped: false };
    const cc = document.createElement('canvas');cc.width = sw;cc.height = sh;
    cc.getContext('2d').drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
    return { url: cc.toDataURL('image/jpeg', 0.92), aspect: sw / sh, isCropped: true };
  } catch (e) {
    return { url: src, aspect: 1, isCropped: false };
  }
}
const normHex = (s) => {let v = String(s || '').trim().replace(/^#/, '');if (/^[0-9a-fA-F]{3}$/.test(v)) v = v.split('').map((c) => c + c).join('');return /^[0-9a-fA-F]{6}$/.test(v) ? '#' + v.toLowerCase() : null;};
async function extractDominantColor(src) {
  const img = await new Promise((res, rej) => {const i = new Image();i.crossOrigin = 'anonymous';i.onload = () => res(i);i.onerror = rej;i.src = src;});
  const W = 72,H = Math.max(1, Math.round(72 * img.height / Math.max(1, img.width)));
  const c = document.createElement('canvas');c.width = W;c.height = H;
  const x = c.getContext('2d');x.drawImage(img, 0, 0, W, H);
  const d = x.getImageData(0, 0, W, H).data;
  const buckets = {};
  for (let i = 0; i < d.length; i += 4) {
    const a = d[i + 3];if (a < 128) continue;
    const r = d[i],g = d[i + 1],b = d[i + 2];
    const mx = Math.max(r, g, b),mn = Math.min(r, g, b);
    if (mx > 244 && mn > 238) continue; // 흰 배경 제외
    const key = (r >> 4) + ',' + (g >> 4) + ',' + (b >> 4);
    const e = buckets[key] || (buckets[key] = { n: 0, r: 0, g: 0, b: 0 });
    e.n++;e.r += r;e.g += g;e.b += b;
  }
  let best = null;for (const k in buckets) {const e = buckets[k];if (!best || e.n > best.n) best = e;}
  if (!best) return '#888888';
  const r = Math.round(best.r / best.n),g = Math.round(best.g / best.n),b = Math.round(best.b / best.n);
  return '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
}
// 이미지의 "배경색" — 테두리(상·하·좌·우 라인) 픽셀 다수결. 흰색도 포함(실제 배경일 수 있음). 투명 테두리면 null.
async function extractBgColor(src) {
  const img = await new Promise((res, rej) => {const i = new Image();i.crossOrigin = 'anonymous';i.onload = () => res(i);i.onerror = rej;i.src = src;});
  const W = 72, H = Math.max(1, Math.round(72 * img.height / Math.max(1, img.width)));
  const c = document.createElement('canvas');c.width = W;c.height = H;
  const x = c.getContext('2d');x.drawImage(img, 0, 0, W, H);
  const d = x.getImageData(0, 0, W, H).data;
  const buckets = {};
  const add = (i) => {if (d[i + 3] < 128) return;const r = d[i], g = d[i + 1], b = d[i + 2];const key = (r >> 4) + ',' + (g >> 4) + ',' + (b >> 4);const e = buckets[key] || (buckets[key] = { n: 0, r: 0, g: 0, b: 0 });e.n++;e.r += r;e.g += g;e.b += b;};
  for (let xx = 0; xx < W; xx++) {add((xx) * 4);add(((H - 1) * W + xx) * 4);}
  for (let yy = 0; yy < H; yy++) {add((yy * W) * 4);add((yy * W + (W - 1)) * 4);}
  let best = null;for (const k in buckets) {const e = buckets[k];if (!best || e.n > best.n) best = e;}
  if (!best) return null;
  const r = Math.round(best.r / best.n), g = Math.round(best.g / best.n), b = Math.round(best.b / best.n);
  return '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
}

// 고급설정 기본값 — 단일 출처(초기 state + 새 이미지 리셋에서 공용). 하드코딩 분산 방지.
const DEFAULT_BG_MODE = 'png';
const DEFAULT_SCORER_STATE = { targetDensity: 1, overlapTol: 0.14, siloMode: 'A', expFloor: 0.25 };
function rankAccessoryCenteredCandidates(list, category, productCount, modelCount) {
  if (!/패션잡화/.test(String(category || '')) || productCount < 2 || modelCount) return list;
  const scored = (list || []).map((cand, index) => {
    const rects = (cand && cand.rects || []).filter((r) => r && r.role !== 'model');
    if (!rects.length) return { cand, index, score: 1e9 };
    let l = Infinity, t = Infinity, rgt = -Infinity, btm = -Infinity;
    const rows = new Set();
    rects.forEach((r) => {
      l = Math.min(l, r.x); t = Math.min(t, r.y); rgt = Math.max(rgt, r.x + r.w); btm = Math.max(btm, r.y + r.h);
      rows.add(Math.floor((r.z || 0) / 10));
    });
    const bounds = cand.bounds || { x: 50, y: 40, w: 450, h: 470 };
    const cxPenalty = Math.abs((l + rgt) / 2 - (bounds.x + bounds.w / 2)) / Math.max(1, bounds.w);
    const cyPenalty = Math.abs((t + btm) / 2 - (bounds.y + bounds.h / 2)) / Math.max(1, bounds.h);
    const bottomRowPenalty = rows.size <= 1 && (t > bounds.y + bounds.h * 0.45 || btm > bounds.y + bounds.h * 0.9) ? 2 : 0;
    const areas = rects.map((x) => x.w * x.h).filter(Boolean);
    const scalePenalty = areas.length > 1 ? (Math.max.apply(null, areas) / Math.max(1, Math.min.apply(null, areas)) - 1) * 0.35 : 0;
    return { cand, index, score: cyPenalty * 1.4 + cxPenalty + bottomRowPenalty + scalePenalty };
  });
  return scored.sort((a, b) => a.score - b.score || a.index - b.index).map((x) => x.cand);
}
function foodKitchenGroupKey(item) {
  if (!item) return '';
  return [item.categoryRoot, item.categoryLeaf, item.category, item.label].filter(Boolean).join('>');
}
function buildFoodKitchenGroupedCandidate(items, bounds) {
  const list = (items || []).filter(Boolean);
  if (!bounds || list.length < 2) return null;
  const rootText = list.map((it) => [it.categoryRoot, it.category, it.categoryLeaf, it.label].filter(Boolean).join(' ')).join(' ');
  if (!/식품|주방|식품\/주방/.test(rootText)) return null;
  const byKey = {};
  list.forEach((it, idx) => {
    const key = foodKitchenGroupKey(it) || 'item-' + idx;
    (byKey[key] = byKey[key] || []).push(it);
  });
  const groups = Object.keys(byKey).map((key) => byKey[key]).sort((a, b) => b.length - a.length);
  if (groups.length < 2 && groups[0] && groups[0].length < 2) return null;
  const rows = [];
  groups.forEach((group) => {
    for (let i = 0; i < group.length; i += 4) rows.push(group.slice(i, i + 4));
  });
  if (!rows.length) return null;
  const gap = 8;
  const rowGap = 10;
  const maxRowH = (bounds.h - rowGap * Math.max(0, rows.length - 1)) / rows.length;
  const rowHs = rows.map((row) => {
    const sumA = row.reduce((s, it) => s + Math.max(0.2, Number(it.aspect) || 1), 0);
    return Math.max(24, Math.min(maxRowH, (bounds.w - gap * Math.max(0, row.length - 1)) / Math.max(1, sumA)));
  });
  const totalH = rowHs.reduce((s, h) => s + h, 0) + rowGap * Math.max(0, rows.length - 1);
  let y = bounds.y + (bounds.h - totalH) / 2;
  const rects = [];
  rows.forEach((row, ri) => {
    const h = rowHs[ri];
    const rowW = row.reduce((s, it) => s + h * Math.max(0.2, Number(it.aspect) || 1), 0) + gap * Math.max(0, row.length - 1);
    let x = bounds.x + (bounds.w - rowW) / 2;
    row.forEach((it, ci) => {
      const aspect = Math.max(0.2, Number(it.aspect) || 1);
      const w = h * aspect;
      rects.push({
        id: it.id != null ? it.id : 'fg' + ri + '-' + ci,
        src: it.src,
        url: it.url || it.src,
        aspect: it.aspect,
        x: Math.round(x * 10) / 10,
        y: Math.round(y * 10) / 10,
        w: Math.round(w * 10) / 10,
        h: Math.round(h * 10) / 10,
        z: ri * 10 + ci,
      });
      x += w + gap;
    });
    y += h + rowGap;
  });
  return {
    id: 'food-kitchen-grouped-' + list.length,
    strategy: 'food-kitchen-grouped',
    strategyLabel: '카테고리 묶음',
    rowsLabel: rows.map((row, i) => (i + 1) + '행 ' + row.length).join(' · '),
    split: rows.map((row) => row.length),
    rects,
    bounds,
    metrics: {},
    scoring: { score: 999 },
    preferred: true,
    arrSig: 'food-kitchen-grouped|' + rows.map((row) => row.map(foodKitchenGroupKey).join('.')).join('/'),
  };
}
function AutoPlaceTyped(props) {
  // 배너 만들기에서 고른 가이드(유형)를 받으면 그 유형으로 시작. 없으면 A(기존 동작 그대로).
  const ga4Src = (props && props.ga4Source) === 'asst' ? 'asst' : 'edit';
  const guide = props && props.guide || typeof window !== 'undefined' && window.__ap550Guide || null;
  const [bannerType, setBannerType] = useS2(() => guideToTypeId(guide) || 'A');
  const manualTypeRef = useSR2(false);
  // [INP-FIX] layoutType: 무거운 배치 계산의 트리거. 칩 클릭 시 bannerType은 즉시 갱신(칩 하이라이트 즉시 반영),
  // layoutType은 2-rAF 뒤에 갱신하여 paint 이후에 generate*/rank* useMemo가 실행되도록 분리.
  const [layoutType, setLayoutType] = useS2(() => guideToTypeId(guide) || 'A');
  const layoutTypeRafRef = useSR2(null); // 대기 중인 rAF 취소 핸들
  const chooseBannerType = (id) => {
    manualTypeRef.current = true;
    setBannerType(id); // 즉시 → 칩 하이라이트 다음 프레임에 paint
    if (layoutTypeRafRef.current !== null) { cancelAnimationFrame(layoutTypeRafRef.current); }
    // double-rAF: 현재 프레임 완료 + paint 후 → 무거운 배치 계산 트리거
    layoutTypeRafRef.current = requestAnimationFrame(() => {
      layoutTypeRafRef.current = requestAnimationFrame(() => {
        layoutTypeRafRef.current = null;
        setLayoutType(id);
      });
    });
  };
  // 가이드가 실제로 바뀔 때(다른 유형 카드 진입)만 유형 전환. 안정적인 key(id/name) 없으면 객체 동일성 흔들림 방지.
  // 사용자가 칩을 이미 클릭(manualTypeRef)한 뒤라면 새 가이드 카드로 이동할 때만 덮는다.
  const appliedGuideKeyRef = useSR2(null);
  useSE2(() => {
    const key = guide ? (guide.id || guide.name || (typeof guide === 'string' ? guide : null)) : null;
    if (key === null || (key === appliedGuideKeyRef.current && manualTypeRef.current)) return;
    appliedGuideKeyRef.current = key;
    const t = guideToTypeId(guide);
    if (t) { manualTypeRef.current = false; setBannerType(t); setLayoutType(t); }
  }, [guide && (guide.id || guide.name || guide)]);
  // 등록 이미지에서 진입하면 그 상품의 커머스 카테고리로 시작(수동 확정) — 없으면 기존 자동 감지.
  const initialCategory = props && props.initialCategory || null;
  const initialRecords = props && props.initialRecords;
  const recordsBySrc = useSM2(() => {
    const cm = typeof window !== 'undefined' && window.__catalogMeta;
    return cm ? cm.buildRecordsBySrc(initialRecords) : {};
  }, [initialRecords]);
  const [category, setCategory] = useS2(initialCategory);
  const [catAuto, setCatAuto] = useS2(!initialCategory);
  const [srcs, setSrcs] = useS2(() => Array.isArray(props && props.initialImages) ? props.initialImages : []); // 단일 업로드 (상품+모델 혼합)
  const [allItems, setAllItems] = useS2([]); // 준비된 아이템 전체
  const [busy, setBusy] = useS2(false);
  const [catBusy, setCatBusy] = useS2(false);
  const [faceData, setFaceData] = useS2({}); // {src: {head, chin, person}} — 패션 얼굴 정렬·인물 그룹
  const [headChinData, setHeadChinData] = useS2(() => {try {return JSON.parse(localStorage.getItem('bannerly_facemarks') || '{}');} catch (e) {return {};}}); // 사용자 지정 머리끝/턱끝 (비율)
  const [editFaceSrc, setEditFaceSrc] = useS2(null);
  const saveFaceMark = (src, head, chin) => {setHeadChinData((p) => {const n = { ...p, [src]: { head, chin } };try {localStorage.setItem('bannerly_facemarks', JSON.stringify(n));} catch (e) {}return n;});setEditFaceSrc(null);};
  const faceCacheRef = useSR2({});
  const [photoFace, setPhotoFace] = useS2({}); // {src: {midXFrac, bodyMidXFrac, crownFrac, faceHFrac, eyeFrac, person}|null} — 화보(사진) 피사체 초점·정렬 (task-011). null=미검출(중앙 폴백)
  const photoFaceCacheRef = useSR2({});
  const [splitContentCrops, setSplitContentCrops] = useS2({}); // [SPLIT-FIX] {src: {url, aspect, isCropped}} — C분할 콘텐츠 bbox 크롭
  const splitContentCropCacheRef = useSR2({});
  const [toast, setToast] = useS2(null);
  const [saveOpen, setSaveOpen] = useS2(false); // 저장 팝업 (행사명·폴더 입력)
  const [pinnedId, setPinnedId] = useS2(null);
  const [patternHint, setPatternHint] = useS2(''); // 배치패턴 섹션 힌트 — IIFE 밖으로 호이스트 (conditional hook 방지)
  const [rankTab, setRankTab] = useS2('all'); // 후보 랭킹 보기: all | box | trap | bundle (풀은 유지, 보기만 나눔)
  const [patLock, setPatLock] = useS2(null); // {id, rects, pid} — 경로 유지한 채 누끼 패턴만 덮음(손편집 아님)
  const [restoredCand, setRestoredCand] = useS2(null); // 이어서 편집: 저장 레이아웃을 '복원됨' 후보로 주입(엔진 후보에 prepend → 다양성 +1)
  const [editRects, setEditRects] = useS2({}); // {candId: rects} — 제품 오브젝트 편집 상태
  const resetStateRef = useSR2(null);
  const preserveSrcChangeRef = useSR2(false);
  const pendingSourceEditRef = useSR2(null);
  const [selectedId, setSelectedId] = useS2(null);
  const [selectedIds, setSelectedIds] = useS2([]);
  const [selLogoIdx, setSelLogoIdx] = useS2(null); // 캔버스에서 클릭한 로고(하단 크기 슬라이더)
  const [decoSelected, setDecoSelected] = useS2(false);
  const [decoScale, setDecoScale] = useS2(1);
  const pendingLogoScalesRef = useSR2(null);
  const pendingLogoScaleWsRef = useSR2(null);
  const pendingLogoScaleHsRef = useSR2(null);
  const onSelectLogo = (idx) => { setSelLogoIdx(idx); if (idx != null) { setSelectedId(null); setDecoSelected(false); } };
  const onSelectProduct = (id) => { setSelectedId(id); if (id != null) { setSelLogoIdx(null); setDecoSelected(false); } };
  const onSelectDeco = () => { if (!deco) return; setDecoSelected(true); setSelLogoIdx(null); setSelectedId(null); };
  // onLogoScale(idx, axis, value): axis='w'|'h', value=scaleW|scaleH multiplier
  const onLogoScale = (idx, axis, value) => {
    setLogoMeta((prev) => prev.map((g, i) => {
      if (i !== idx) return g;
      const v = logoScale550(value);
      return axis === 'h' ? { ...g, scaleH: v } : { ...g, scaleW: v };
    }));
  };
  const setLogoOrient550 = (idx, orient) => {
    preserveCurrentPlacementForOverlay();
    setLogoMeta((prev) => prev.map((g, i) => i === idx ? { ...g, orient } : g));
    setSelLogoIdx(idx);
  };
  const [history, setHistory] = useS2([]);
  const [redoList, setRedo] = useS2([]);
  const [catOpen, setCatOpen] = useS2(false);
  const [advOpen, setAdvOpen] = useS2(true);
  const [guideOn, setGuideOn] = useS2(true); // 여백 가이드 오버레이 on/off (sticky 칩 바 체크박스)
  const [bgMode, setBgMode] = useS2(DEFAULT_BG_MODE);
  const [imageAdjust, setImageAdjust] = useS2(() => Object.assign({}, (window.BannerImageTools && window.BannerImageTools.DEFAULTS) || {}));
  const [imageAdjustBySource, setImageAdjustBySource] = useS2({});
  const [outpaintVariants, setOutpaintVariants] = useS2([]);
  const BG_OPTS = [{ id: 'remove', label: '배경 제거' }, { id: 'png', label: '투명 PNG' }, { id: 'keep', label: '배경 유지' }];
  const [scorer, setScorer] = useS2({ ...DEFAULT_SCORER_STATE });
  // D 풀이미지형 — 화보(원본 사진) 목록 · 초점 편집 · 1순위 고정
  const [fullItems, setFullItems] = useS2([]); // [{src,url,aspect}] — D/C 공용 원본 화보
  const [fullFocals, setFullFocals] = useS2({}); // {candId: {x,y}} 사용자 조정 초점
  const [fullPinned, setFullPinned] = useS2(null);
  // C 분할형 — 패널별 초점 편집 · 1순위 고정
  const [splitFocals, setSplitFocals] = useS2({}); // {candId+':'+panelIdx: {x,y}}
  const [splitPinned, setSplitPinned] = useS2(null);
  const [splitPanelOrders, setSplitPanelOrders] = useS2({}); // {candId: [src]} — 패널끼리 드래그해 바꾼 이미지 순서
  // B 분할+누끼형 — 누끼 상품(좌 클러스터) · 화보(우 패널). 화보는 자동판별 + 사용자가 썸네일로 직접 지정 가능.
  const [bNukki, setBNukki] = useS2([]); // A-ready 누끼 아이템(좌)
  const [bPhoto, setBPhoto] = useS2(null); // {src,url,aspect} 화보(우)
  const [bPhotoSrc, setBPhotoSrc] = useS2(null); // 사용자가 지정한 화보 src(없으면 자동)
  const [bFocal, setBFocal] = useS2(null); // 우 화보 초점
  const [bPinned, setBPinned] = useS2(null);
  const [bBusy, setBBusy] = useS2(false);
  // E 보험 — 보험명 텍스트 · 모델 지정(우측, 선택). 로고/부가정보는 공용. 나머지 업로드=일러스트.
  const [eName, setEName] = useS2(''); // 보험명(줄바꿈=여러 줄)
  const [eModelSrc, setEModelSrc] = useS2(null); // 인물누끼로 지정한 src(없으면 로고형). 자동 인식되며 토글로 override.
  const eModelTouchedRef = useSR2(false); // 사용자가 인물누끼 토글을 직접 눌렀는지(자동 인식 존중/무시)
  const eModelClassRef = useSR2({}); // {src: bool} 인물(모델) 판별 캐시
  const [eIllustContain, setEIllustContain] = useS2(false); // 배경: 투명 일러스트=contain(전체맞춤) / 불투명 화보=cover(꽉채움)
  const eIllustKindRef = useSR2({}); // {src: 'photo'|'nukki'} 투명도 판별 캐시
  const [eBgMode, setEBgMode] = useS2('auto'); // (구) 보험 배경 모드 — eBgChip 도입 후 미사용
  const [eBgChip, setEBgChip] = useS2(null); // 배경: null=자동(이미지 배경색) / hex=단색 / 'multi'=멀티컬러(그라데이션)
  const [eBgDom, setEBgDom] = useS2(null); // 이미지 대표색(멀티컬러 그라데이션 2번째 스톱)
  const [eBgTint, setEBgTint] = useS2(null);
  const [eTextColor, setETextColor] = useS2(null); // 보험명 텍스트 색 = 로고 대표색
  const [eFocal, setEFocal] = useS2(null); // 모델 초점
  // 선택사항(우측)
  const [logoSrcs, setLogoSrcs] = useS2([]); // 로고 (독립, 최대 3)
  const [logoMeta, setLogoMeta] = useS2([]); // 로고 메타(비율·가로/세로형) — 캔버스 오버레이 & 예약 높이
  const [exSel, setExSel] = useS2(null); // 택1: 'text'|'image'|'flag'|'mark'
  const [addText, setAddText] = useS2('');
  const [addImgSrc, setAddImgSrc] = useS2(null);
  const [addImgKind, setAddImgKind] = useS2(null); // 'nukki' | 'photo'
  const [addImgPos, setAddImgPos] = useS2({ x: 50, y: 50, zoom: 1 }); // 부가이미지 초점(%) + 확대/축소
  const [decoPos, setDecoPos] = useS2(null); // 부가정보 배지 캔버스 자유 위치({x,y}); null이면 기본 위치
  const [markType, setMarkType] = useS2(null);
  const [addImgType, setAddImgType] = useS2('사은품'); // 부가이미지 유형 택1
  const [addImgTitle, setAddImgTitle] = useS2('사은품'); // 부가 이미지 상단 타이틀
  const [chipColors, setChipColors] = useS2([]); // 컬러칩 팔레트 (hex[])
  const [chipMethod, setChipMethod] = useS2('hex'); // 'hex' | 'image'
  const [chipHexInput, setChipHexInput] = useS2('');
  const [chipBusy, setChipBusy] = useS2(false);
  const [textBusy, setTextBusy] = useS2(false);
  const [flagCount, setFlagCount] = useS2(1); // 인증 플래그 노출 개수 (1~2)
  const [flagItems, setFlagItems] = useS2([makeFlagItem(), makeFlagItem()]); // 인증 플래그별 텍스트/이미지
  // 불러오기 탭
  const [imgTab, setImgTab] = useS2('upload'); // 'upload' | 'library'
  const [nukkiSel, setNukkiSel] = useS2([]); // 누끼 편집 대상으로 선택된 이미지 src 목록 (업로드 탭)
  const [libDb, setLibDb] = useS2(null); // category-sample-index.json lazy load
  const [libDbBusy, setLibDbBusy] = useS2(false);
  const [libMidInput, setLibMidInput] = useS2('');
  const [libMidResults, setLibMidResults] = useS2(null); // {found:[],missing:[]} | null

  const upRef = useSR2(null),logoRef = useSR2(null),addImgRef = useSR2(null),replaceRef = useSR2(null),catBoxRef = useSR2(null);
  const flagImgRefs = [useSR2(null), useSR2(null)];
  const catCacheRef = useSR2({});
  const pendingRef = useSR2(null),undoRef = useSR2(null),redoRef = useSR2(null);
  const chipImgRef = useSR2(null);
  const replaceIdxRef = useSR2(null);
  const thumbDragRef = useSR2(null);
  const pendingSplitOrderRef = useSR2(false);
  // 카탈로그 불러오기: 원본 CDN URL → blob URL 매핑. 토글 on/off 시 대응하는 blob URL을 추적한다.
  const catalogBlobMapRef = useSR2({}); // originalUrl → blobUrl
  const catalogOriginalByLocalRef = useSR2({}); // blob/dataUrl → originalUrl
  const catalogRecordBySourceRef = useSR2({}); // original/blob/dataUrl → catalog product record
  const catalogDlSetRef = useSR2(new Set()); // 현재 다운로드 중인 originalUrl 집합(중복 방지)

  const rule = category ? catRule(category) : DEFAULT_RULE;
  const showToast = (m) => {setToast(m);setTimeout(() => setToast(null), 2400);};
  const resetGlobalImageAdjust = () => setImageAdjust(Object.assign({}, (window.BannerImageTools && window.BannerImageTools.DEFAULTS) || {}));
  const readAsDataURL = (file) => new Promise((res) => {const r = new FileReader();r.onload = () => res(r.result);r.readAsDataURL(file);});
  const filesToUrls = async (files) => Promise.all(Array.from(files || []).filter((f) => f.type.startsWith('image/')).map(readAsDataURL));
  const addImages = async (files) => {const u = await filesToUrls(files);if (u.length) {manualTypeRef.current = false;resetGlobalImageAdjust();setSrcs((p) => [...p, ...u]);}};
  // 카탈로그 URL을 srcs에 추가/제거하는 토글. 추가 시 원본을 로컬 blob으로 다운받아 사용한다.
  // catalogBlobMapRef: originalUrl → blobUrl. 이를 통해 토글 off 시 blob URL을 srcs에서 정확히 제거.
  const registerCatalogSource = (src, originalUrl, record) => {
    if (!src) return;
    if (originalUrl) catalogOriginalByLocalRef.current[src] = originalUrl;
    if (record) {
      catalogRecordBySourceRef.current[src] = record;
      if (originalUrl) catalogRecordBySourceRef.current[originalUrl] = record;
    }
  };
  const originalForSource = (src) => catalogOriginalByLocalRef.current[src] || src;
  const recordForSource = (src) => catalogRecordBySourceRef.current[src] || catalogRecordBySourceRef.current[originalForSource(src)] || recordsBySrc[src] || recordsBySrc[originalForSource(src)];
  const isCatalogSource = (src) => !!(catalogOriginalByLocalRef.current[src] || catalogRecordBySourceRef.current[src] || catalogRecordBySourceRef.current[originalForSource(src)]);
  const transferSourceMeta = (oldSrc, nextSrc) => {
    if (!oldSrc || !nextSrc) return;
    const original = originalForSource(oldSrc);
    const record = recordForSource(oldSrc);
    registerCatalogSource(nextSrc, original !== oldSrc ? original : null, record);
    if (catCacheRef.current[oldSrc] !== undefined) catCacheRef.current[nextSrc] = catCacheRef.current[oldSrc];
    else if (catCacheRef.current[original] !== undefined) catCacheRef.current[nextSrc] = catCacheRef.current[original];
    if (faceCacheRef.current[oldSrc] !== undefined) faceCacheRef.current[nextSrc] = faceCacheRef.current[oldSrc];
    if (photoFaceCacheRef.current[oldSrc] !== undefined) photoFaceCacheRef.current[nextSrc] = photoFaceCacheRef.current[oldSrc];
    if (splitContentCropCacheRef.current[oldSrc] !== undefined) splitContentCropCacheRef.current[nextSrc] = splitContentCropCacheRef.current[oldSrc];
  };
  const toggleCatalogUrl = (url, record) => {
    const r = category ? catRule(category) : DEFAULT_RULE;
    const maxLen = r.prodMax + r.modelMax;
    // 이미 다운로드된 blob URL이 srcs에 있으면 제거(토글 off)
    const existingBlob = catalogBlobMapRef.current[url];
    if (existingBlob && srcs.includes(existingBlob)) {
      removeSourcesPreservePlacement([existingBlob]);
      delete catalogBlobMapRef.current[url];
      delete catalogOriginalByLocalRef.current[existingBlob];
      return;
    }
    // 같은 URL(raw)이 있으면 제거 — 폴백 안전장치
    if (srcs.includes(url)) { removeSourcesPreservePlacement([url]); return; }
    // 다운로드 중이면 중복 요청 방지
    if (catalogDlSetRef.current.has(url)) return;
    // 최대 장수 체크
    if (srcs.length >= maxLen) { showToast(`이미지는 최대 ${maxLen}장까지 추가할 수 있어요`); return; }
    // 원본 CDN URL을 로컬 blob으로 다운받은 뒤 srcs에 추가
    catalogDlSetRef.current.add(url);
    const P = window.BannerEditorPersist;
    (P ? P.downloadOriginal(url) : Promise.resolve(null)).then((blobUrl) => {
      catalogDlSetRef.current.delete(url);
      if (blobUrl) {
        catalogBlobMapRef.current[url] = blobUrl;
        registerCatalogSource(blobUrl, url, record);
        manualTypeRef.current = false;
        resetGlobalImageAdjust();
        setSrcs((prev) => {
          if (prev.length >= maxLen) { showToast(`이미지는 최대 ${maxLen}장까지 추가할 수 있어요`); return prev; }
          return [...prev, blobUrl];
        });
      } else {
        showToast('이미지 다운로드에 실패했습니다');
      }
    }).catch(() => { catalogDlSetRef.current.delete(url); showToast('이미지 다운로드에 실패했습니다'); });
  };
  // category-sample-index.json lazy load
  const ensureLibDb = () => {
    if (libDb || libDbBusy) return;
    setLibDbBusy(true);
    fetch('data/cj-catalog.json?v=gnb-leaf-20260819', { cache: 'no-store' })
      .then((r) => r.json())
      .then((d) => setLibDb(d))
      .catch(() => setLibDb(null))
      .finally(() => setLibDbBusy(false));
  };
  // 선택 카테고리(GNB 루트)에 해당하는 이미지 목록 (primaryImage 기준, 상품 단위)
  // taxonomy.path[0] === category 직접 필터 (이전: ROOT_TO_COMMERCE 역인덱스 방식 → 14루트 전환으로 불필요).
  const libCatProducts = useSM2(() => {
    if (!libDb || !category) return [];
    const cats = (libDb.categories || []).filter((c) => {
      const p0 = c.taxonomy && ((c.taxonomy.path && c.taxonomy.path[0]) || c.taxonomy.large);
      return p0 === category;
    });
    return cats.flatMap((c) => c.products || []).slice(0, 60);
  }, [libDb, category]);
  // MID 검색 실행
  const searchByMids = () => {
    const raw = libMidInput.trim();
    if (!raw || !libDb) return;
    const mids = raw.split(/[\s,]+/).filter(Boolean);
    const allProducts = (libDb.categories || []).flatMap((c) => c.products || []);
    const found = [], missing = [];
    mids.forEach((m) => {
      const hit = allProducts.find((p) => String(p.mid) === m);
      if (hit) found.push(hit); else missing.push(m);
    });
    setLibMidResults({ found, missing });
  };
  const normalizeCatalogUrl = (url) => {
    if (!url) return '';
    const raw = String(url).trim();
    if (!raw) return '';
    if (/^https?:\/\//i.test(raw)) return raw;
    if (raw.startsWith('//')) return `https:${raw}`;
    if (raw.startsWith('/public/confirm/')) return `https://itemimage.cjonstyle.net${raw}`;
    return raw;
  };
  // 그리드 썸네일 전용 — sourceUrl의 fit-in 크기를 160x160 WebP로 교체 (로컬 path 대신 CDN 경량 이미지 사용)
  // 캔버스에 추가할 때는 원본 primaryImage/path를 그대로 사용 (이 함수는 <img src>에만 적용)
  const toThumbUrl = (p) => {
    const src = normalizeCatalogUrl(p.images && p.images[0] && p.images[0].sourceUrl);
    if (src) return src.replace(/\/fit-in\/\d+x\d+\//, '/fit-in/160x160/filters:format(webp)/');
    return normalizeCatalogUrl(p.primaryImage || (p.images && p.images[0] && p.images[0].path));
  };
  const catalogCanvasUrl = (p) => normalizeCatalogUrl((p && p.images && p.images[0] && p.images[0].sourceUrl) || (p && (p.primaryImage || (p.images && p.images[0] && p.images[0].path))) || '');
  // 누끼 팝업(window.NukkiPopup) 열기.
  // - 업로드 탭: 썸네일을 클릭해 선택한 이미지(nukkiSel)를 팝업에 전달.
  // - 불러오기 탭: 누끼 완료된 dataURL도 재편집할 수 있도록 현재 이미지 전체를 전달.
  // - 완료 시: 결과 dataUrl로 원본 src를 교체(같은 위치 유지). 원본이 srcs에 없으면 추가.
  const openNukkiPopupFor = (imagesToSend) => {
    if (!(window.NukkiPopup && window.NukkiPopup.open)) {showToast('누끼 도구를 불러오지 못했어요 (스크립트 로드 확인)');return;}
    if (!imagesToSend.length) {showToast('누끼를 만들 이미지를 선택해 주세요');return;}
    window.NukkiPopup.open({
      images: imagesToSend.map((src) => ({ src, nukki: true })),
      onComplete: (results) => {
        const replacements = (results || []).map((result, index) => {
          const oldSrc = imagesToSend[index];
          const nextSrc = result && result.ok && result.nukki && result.dataUrl;
          if (oldSrc && nextSrc) transferSourceMeta(oldSrc, nextSrc);
          return { oldSrc, oldKeys: Array.from(sourceKeys([oldSrc])), nextSrc };
        }).filter((pair) => pair.oldSrc && pair.nextSrc);
        if (editCand && replacements.length) {
          pendingSourceEditRef.current = { cand: { ...editCand }, rects: rectsFor(editCand).map((rect) => ({ ...rect })), replacements, layoutType };
          preserveSrcChangeRef.current = true;
        }
        setSrcs((prev) => {
          const next = [...prev];
          (results || []).forEach((r, i) => {
            const used = r && r.ok && r.nukki && r.dataUrl;
            if (!used) return;
            const origSrc = imagesToSend[i];
            const idx = next.indexOf(origSrc);
            if (idx !== -1) {
              next[idx] = used;
            } else if (!next.includes(used)) {
              next.push(used);
            }
          });
          return next;
        });
        setNukkiSel([]);
        if (window.__ga4 && window.__ga4.trackNukki) window.__ga4.trackNukki(ga4Src);
      },
      onCancel: () => {} });
  };
  const openNukkiPopup = () => {
    const selectedUploads = nukkiSel.filter((src) => srcs.includes(src));
    const imagesToSend = imgTab === 'upload'
      ? (selectedUploads.length ? selectedUploads : srcs.slice())
      : srcs.slice();
    openNukkiPopupFor(imagesToSend);
  };
  const editSingleSrc = (src) => openNukkiPopupFor(srcs.includes(src) ? [src] : []);
  const addLogos = async (files) => {const u = await filesToUrls(files);if (u.length) {preserveCurrentPlacementForOverlay();setLogoSrcs((p) => [...p, ...u].slice(0, LOGO_550_MAX_COUNT));}};
  const applyLibraryModels = (assets) => {manualTypeRef.current = false;resetGlobalImageAdjust();setSrcs((prev) => Array.from(new Set(prev.concat((assets || []).map((a) => a.src).filter(Boolean)))).slice(0, rule.prodMax + rule.modelMax));};
  const applyLibraryLogos = (assets) => {preserveCurrentPlacementForOverlay();setLogoSrcs((prev) => Array.from(new Set(prev.concat((assets || []).map((a) => a.src).filter(Boolean)))).slice(0, LOGO_550_MAX_COUNT));};
  const setAddImg = async (files) => {const u = await filesToUrls(files);if (!u.length) return;preserveCurrentPlacementForOverlay();setAddImgSrc(u[0]);setAddImgPos({ x: 50, y: 50, zoom: 1 });setAddImgKind(await detectImageKind(u[0]));};
  const clearAddImg = () => {preserveCurrentPlacementForOverlay();setAddImgSrc(null);setAddImgKind(null);setAddImgPos({ x: 50, y: 50, zoom: 1 });};
  const labelSourceAsModel = async (src) => {
    if (!src) return false;
    if (isCatalogFoodKitchen(recordForSource(src))) return false;
    let label = catCacheRef.current[src];
    if (label === undefined && (window.__mpClassify || (window.__bannerLLM && window.__bannerLLM.available()))) {
      try { label = await classifyVision(src, recordForSource(src)); } catch (e) { label = null; }
      catCacheRef.current[src] = label || null;
    }
    if (label === undefined && isCatalogSource(src) && /패션|의류|언더웨어|스포츠/.test(String(category || ''))) return null;
    return label === '모델';
  };
  const dz = (handler) => ({ onDragOver: (e) => {e.preventDefault();e.stopPropagation();e.currentTarget.classList.add('drop-over');}, onDragLeave: (e) => e.currentTarget.classList.remove('drop-over'), onDrop: (e) => {e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-over');handler(e.dataTransfer.files);} });
  const updateFlagItem = (idx, patch) => {
    setFlagItems((prev) => {
      const next = normalizeFlagItems(prev);
      next[idx] = { ...next[idx], ...patch };
      return next;
    });
  };
  const setFlagImg = async (idx, files) => {
    const u = await filesToUrls(files);
    if (!u.length) return;
    updateFlagItem(idx, { mode: 'image', src: u[0] });
  };
  const clearFlagImg = (idx) => updateFlagItem(idx, { src: null });
  const addSourceKeys = (keys, value) => { if (value) keys.add(value); };
  const itemMatchesSources = (it, keys) => !!it && (keys.has(it.src) || keys.has(it.url));
  const rectMatchesSources = (rect, keys) => !!rect && (keys.has(rect.src) || keys.has(rect.url));
  const preserveCandidate = (cand, rects) => {
    if (!cand) return null;
    const id = String(cand.id || 'layout') + '-preserved-' + rects.length;
    const next = { ...cand, id, rects, _preservedLayout: layoutType, strategyLabel: '현재 배치 유지', rowsLabel: (cand.rowsLabel || '') + ' · 배치 유지' };
    if (window.AP550 && window.AP550.computeMetrics) next.metrics = window.AP550.computeMetrics(rects, cand.bounds);
    setRestoredCand(next);
    setEditRects((prev) => ({ ...prev, [id]: rects }));
    if (layoutType === 'B') setBPinned(id); else setPinnedId(id);
    return next;
  };
  const sourceKeys = (targets) => {
    const keys = new Set();
    (targets || []).forEach((target) => {
      addSourceKeys(keys, target);
      addSourceKeys(keys, originalForSource(target));
      const item = allItems.find((it) => it && (it.src === target || it.url === target)) || bNukki.find((it) => it && (it.src === target || it.url === target));
      if (item) { addSourceKeys(keys, item.src); addSourceKeys(keys, item.url); addSourceKeys(keys, originalForSource(item.src)); addSourceKeys(keys, originalForSource(item.url)); }
      Object.keys(catalogOriginalByLocalRef.current || {}).forEach((local) => {
        if (catalogOriginalByLocalRef.current[local] === target) addSourceKeys(keys, local);
      });
    });
    return keys;
  };
  const removeSourcesPreservePlacement = (targets) => {
    const keys = sourceKeys(targets);
    if (!keys.size) return;
    const cand = editCand;
    if (cand) {
      const before = rectsFor(cand);
      const after = before.filter((rect) => !rectMatchesSources(rect, keys));
      if (after.length && after.length !== before.length) preserveCandidate(cand, after);
      if (!after.length && after.length !== before.length) {
        setRestoredCand(null);setEditRects({});setPinnedId(null);setBPinned(null);setSplitPinned(null);setFullPinned(null);setPatLock(null);setHistory([]);setRedo([]);
      }
    }
    preserveSrcChangeRef.current = true;
    setSrcs((prev) => prev.filter((src) => !keys.has(src)));
    setNukkiSel((prev) => prev.filter((src) => !keys.has(src)));
    setAllItems((prev) => prev.filter((it) => !itemMatchesSources(it, keys)));
    if (bPhotoSrc && keys.has(bPhotoSrc)) setBPhotoSrc(null);
    if (eModelSrc && keys.has(eModelSrc)) setEModelSrc(null);
    setRestoredCand(null);setEditRects({});setPinnedId(null);setBPinned(null);setSplitPinned(null);setFullPinned(null);setPatLock(null);setHistory([]);setRedo([]);
    setSplitPanelOrders({});
    setFocalHist([]);setFocalRedo([]);
    setSelectedId(null);
  };
  const removeSrc = (i) => {
    const oldSrc = srcs[i];
    if (oldSrc) removeSourcesPreservePlacement([oldSrc]);
    if (bannerType === 'C') pendingSplitOrderRef.current = true;
  };
  const requestReplaceSrc = (idx) => {replaceIdxRef.current = idx;if (replaceRef.current) replaceRef.current.click();};
  const replaceSrcAt = async (files) => {
    const idx = replaceIdxRef.current;
    replaceIdxRef.current = null;
    if (idx == null) return;
    const u = await filesToUrls(files);
    if (!u.length) return;
    const nextSrc = u[0];
    const oldSrc = srcs[idx];
    if (!oldSrc) return;
    const cand = editCand;
    if (cand) {
      pendingSourceEditRef.current = { cand: { ...cand }, rects: rectsFor(cand).map((rect) => ({ ...rect })), oldKeys: Array.from(sourceKeys([oldSrc])), nextSrc, layoutType };
    }
    preserveSrcChangeRef.current = true;
    setSrcs((prev) => prev.map((s, i) => i === idx ? nextSrc : s));
    setNukkiSel((prev) => prev.map((s) => s === oldSrc ? nextSrc : s));
    if (bPhotoSrc === oldSrc) setBPhotoSrc(nextSrc);
    if (eModelSrc === oldSrc) setEModelSrc(nextSrc);
    if (bannerType === 'C') pendingSplitOrderRef.current = true;
  };
  const moveSrc = (from, to) => {
    if (from === to || from < 0 || to < 0 || from >= srcs.length || to >= srcs.length) return;
    const cand = editCand;
    if (cand) preserveCandidate(cand, rectsFor(cand).map((rect) => ({ ...rect })));
    preserveSrcChangeRef.current = true;
    setSrcs((prev) => {
      const next = prev.slice();
      const picked = next.splice(from, 1)[0];
      next.splice(to, 0, picked);
      return next;
    });
    if (bannerType === 'C') pendingSplitOrderRef.current = true;
  };
  const onThumbDragStart = (e, idx) => {thumbDragRef.current = idx;e.dataTransfer.effectAllowed = 'move';try {e.dataTransfer.setData('text/plain', String(idx));} catch (err) {}};
  const onThumbDrop = (e, idx) => {e.preventDefault();e.stopPropagation();const from = thumbDragRef.current != null ? thumbDragRef.current : parseInt(e.dataTransfer.getData('text/plain'), 10);thumbDragRef.current = null;if (Number.isFinite(from)) moveSrc(from, idx);};
  // 캔버스 X 버튼 → 해당 rect의 원본 src를 srcs에서 제거. src → url → id(itN) 순으로 매칭.
  const onCanvasDeleteItem = (r) => {
    if (r.src && srcs.includes(r.src)) { removeSourcesPreservePlacement([r.src]); return; }
    const mBn = r.id && r.id.match(/^bn(\d+)$/);
    if (mBn) {
      const it = bNukki[parseInt(mBn[1], 10)];
      if (it && it.src) { removeSourcesPreservePlacement([it.src]); return; }
    }
    const mIt = r.id && r.id.match(/^it(\d+)$/);
    if (mIt) {
      const it = allItems[parseInt(mIt[1], 10)];
      if (it && it.src) { removeSourcesPreservePlacement([it.src]); return; }
    }
    const byUrl = allItems.find((it) => it.url === r.url) || bNukki.find((it) => it.url === r.url);
    if (byUrl && byUrl.src) { removeSourcesPreservePlacement([byUrl.src]); return; }
    if (r.url && srcs.includes(r.url)) removeSourcesPreservePlacement([r.url]);
  };
  const clearAll = () => {setSrcs([]);setNukkiSel([]);setAllItems([]);setCategory(null);setCatAuto(true);setPinnedId(null);setLogoSrcs([]);setExSel(null);setAddText('');setAddImgSrc(null);setMarkType(null);setAddImgType('사은품');setAddImgTitle('사은품');setChipColors([]);setChipHexInput('');setFlagCount(1);setFlagItems([makeFlagItem(), makeFlagItem()]);setAddImgKind(null);setAddImgPos({ x: 50, y: 50, zoom: 1 });setDecoPos(null);setDecoSelected(false);setDecoScale(1);catalogBlobMapRef.current = {};catalogOriginalByLocalRef.current = {};catalogRecordBySourceRef.current = {};catalogDlSetRef.current = new Set();};
  const loadSample = () => {const R = (window.__resources) || {};setSrcs(['1', '2', '3', '4', '5'].map((n) => R['sb' + n] || `uploads/1_M1235580_${n}-removebg-preview.png`));};
  const isDigitalCategoryForFlag = () => !category || /디지털|가전/.test(String(category));
  const pickExclusive = (id) => {
    if (id === 'mark') return;
    if (id === 'flag' && !isDigitalCategoryForFlag()) {
      showToast('인증 플래그는 디지털 상품 카테고리에서만 사용할 수 있어요');
      return;
    }
    preserveCurrentPlacementForOverlay();
    setExSel((v) => {
      const next = v === id ? null : id;
      setDecoSelected(!!next);
      if (!next) setDecoScale(1);
      return next;
    });
  };
  useSE2(() => {
    const invalidExclusive =
      exSel === 'mark' ||
      (exSel === 'flag' && !isDigitalCategoryForFlag()) ||
      (exSel && bannerType === 'E' && exSel !== 'image') ||
      (exSel && bannerType !== 'A' && bannerType !== 'D' && bannerType !== 'E');
    if (invalidExclusive) { setExSel(null); setDecoSelected(false); }
  }, [bannerType, category, exSel]);
  const appendChipColor = (value) => {
    const hex = normHex(value);
    if (!hex) return false;
    let added = false;
    setChipColors((prev) => {
      if (prev.length >= CHIP_MAX || prev.includes(hex)) return prev;
      added = true;
      return [...prev, hex];
    });
    return added;
  };
  const addChipHex = () => {const h = normHex(chipHexInput);if (!h || chipColors.length >= CHIP_MAX) return;appendChipColor(h);setChipHexInput('');};
  const addChipFromImage = async (files) => {const u = await filesToUrls(files);if (!u.length || chipColors.length >= CHIP_MAX) return;setChipBusy(true);try {appendChipColor(await extractDominantColor(u[0]));} catch (e) {showToast('대표색 추출에 실패했어요');} finally {setChipBusy(false);}};
  const pickChipFromScreen = async () => {
    if (!window.EyeDropper) {showToast('이 브라우저는 화면 스포이드를 지원하지 않아요');return;}
    if (chipColors.length >= CHIP_MAX) {showToast(`컬러칩은 최대 ${CHIP_MAX}개까지 추가할 수 있어요`);return;}
    try {
      const picked = await new window.EyeDropper().open();
      const hex = normHex(picked && picked.sRGBHex);
      if (!hex) return;
      setChipHexInput(hex);
      appendChipColor(hex);
      setChipMethod('hex');
      showToast('스포이드 색을 컬러칩에 추가했습니다');
    } catch (e) {
      if (!e || e.name !== 'AbortError') showToast('스포이드 색상 선택에 실패했어요');
    }
  };
  const removeChip = (i) => setChipColors((p) => p.filter((_, k) => k !== i));
  const autoFormatText = async () => {
    const s = addText.trim();if (!s) return;
    if (!(window.__bannerLLM && window.__bannerLLM.available())) {setAddText(localFormatText(s));return;}
    setTextBusy(true);
    try {
      const prompt = '배너 부가문구를 가이드에 맞게 정리해라. 규칙: 공백 제외 8자 이하, 최대 3줄. 의미 단위로 줄을 나눠 각 줄을 줄바꿈으로 구분해 정리된 문구만 출력해라. 따옴표·설명 금지. 예: "렌탈비1개월면제"는 세 줄(렌탈비 / 1개월 / 면제)로, "싱글플러스더블"은 두 줄(싱글+ / 더블)로. 입력: ' + s;
      const r = await window.__bannerLLM.complete({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }] });
      let out = String(r || '').trim();out = out.split(' / ').join(NL).split('/').join(NL);
      out = out.split(NL).map((x) => x.trim()).filter(Boolean).slice(0, 3).join(NL);
      if (out) setAddText(out);
    } catch (e) {showToast('자동 정리에 실패했어요');} finally {setTextBusy(false);}
  };

  // 준비 (배경 처리)
  useSE2(() => {
    let cancelled = false;
    (async () => {
      // 화보/분할 유형(D 풀이미지·C 분할·B 분할+누끼)은 A의 상품/모델 분류 파이프라인이 불필요 → 끔(코드 삭제 아님, 실행만 스킵).
      // (B는 자체 effect에서 누끼/화보만 판별·준비함.) allItems가 비면 하위 분류/얼굴/카테고리 effect 자동 무동작. A로 돌아오면 정상 재실행.
      if (bannerType === 'D' || bannerType === 'C' || bannerType === 'B' || bannerType === 'E') {setAllItems([]);setBusy(false);return;}
      if (!srcs.length) {setAllItems([]);return;}
      setBusy(true);
      try {
        const prepared = [];
        const cm = typeof window !== 'undefined' && window.__catalogMeta;
        const cc = typeof window !== 'undefined' && window.__commerceCategory;
        for (let i = 0; i < srcs.length; i++) {
          let it = { ...(await window.AP550.prepareItem(srcs[i], bgMode)), id: 'it' + i, src: srcs[i] };
          const rec = recordForSource(srcs[i]);
          if (cm && rec) it = cm.applyToItem(it, rec, cc);
          prepared.push(it);
        }
        if (cancelled) return;
        prepared.forEach((it) => {const c = catCacheRef.current[it.src];if (c) it.label = c;});
        setAllItems(prepared);
      } finally {if (!cancelled) setBusy(false);}
    })();
    return () => {cancelled = true;};
  }, [srcs, bgMode, bannerType, recordsBySrc]);

  // 화보/보험 유형(D 풀이미지·C 분할·E 보험): 업로드 이미지를 원본 그대로 로드(비율만 측정). 누끼 파이프라인과 분리.
  useSE2(() => {
    if (bannerType !== 'D' && bannerType !== 'C' && bannerType !== 'E') return;
    if (!srcs.length) {setFullItems([]);eModelTouchedRef.current = false;return;} // 업로드 비면 자동인식 재개
    let cancelled = false;
    (async () => {
      const arr = [];
      for (const s of srcs) {
        try {const im = await new Promise((res, rej) => {const i = new Image();i.onload = () => res(i);i.onerror = rej;i.src = s;});arr.push({ src: s, url: s, aspect: im.naturalWidth / Math.max(1, im.naturalHeight) });} catch (e) {}
      }
      if (!cancelled) setFullItems(arr);
    })();
    return () => {cancelled = true;};
  }, [srcs, bannerType]);

  // [보험] 인물 누끼 자동 인식 — 인물 점유율(프레임을 얼마나 채우나) 기준. 순서 무관.
  //  · 단일 이미지는 무조건 배경(인물 자동배치는 배경+인물 2장 이상일 때만).
  //  · 후보 = 투명 누끼(detectImageKind) + 사람(__mpHead) + 세로 컷아웃(aspect<PERSON_MAX_ASP). 점수=faceHFrac(점유율).
  //    → 넓거나 정사각인 씬 일러스트(사람 그려져 있어도)는 aspect에서 걸러져 배경 유지. 세로 인물 컷아웃만 후보.
  //  · faceHFrac ≥ PERSON_MIN_FACE 후보 중 점유율 가장 큰 것을 인물로. 수동 토글(eModelTouchedRef) 있으면 자동 중단.
  useSE2(() => {
    if (bannerType !== 'E') return;
    if (eModelTouchedRef.current) return;
    if (fullItems.length < 2) return; // 단일 이미지 = 배경
    const PERSON_MIN_FACE = 0.09, PERSON_MAX_ASP = 0.9;
    let cancelled = false;
    (async () => {
      let best = null; // { src, face }
      for (const it of fullItems) {
        if (cancelled) return;
        if ((it.aspect || 1) >= PERSON_MAX_ASP) continue; // 세로 컷아웃 아님(넓/정사각 일러스트) → 배경
        let face = eModelClassRef.current[it.src]; // 인물 점유율(투명+사람) 또는 -1
        if (face === undefined) {
          face = -1;
          try {
            if ((await detectImageKind(it.src)) === 'nukki' && window.__mpHead) {
              const r = await window.__mpHead(it.src, { raw: true });
              if (r && r.faceHFrac != null) face = r.faceHFrac;
            }
          } catch (e) {}
          eModelClassRef.current[it.src] = face;
        }
        if (face >= PERSON_MIN_FACE && (!best || face > best.face)) best = { src: it.src, face };
      }
      if (!cancelled && best && best.src !== eModelSrc) setEModelSrc(best.src);
    })();
    return () => {cancelled = true;};
  }, [fullItems, bannerType]);

  // B 분할+누끼형: 화보(우 패널) 1장 결정 → 나머지는 누끼 상품(좌 클러스터).
  //  화보 결정 = 사용자 지정(bPhotoSrc) 우선, 없으면 자동판별(사진=화보). 누끼는 prepareItem(A와 동일 준비).
  //  HARD RULE: 인물누끼(분류='모델')는 좌측 클러스터 금지 — 화보가 없으면 인물누끼가 우 패널이 됨.
  useSE2(() => {
    if (bannerType !== 'B') return;
    if (!srcs.length) {setBNukki([]);setBPhoto(null);return;}
    let cancelled = false;setBBusy(true);
    (async () => {
      let photoSrc = bPhotoSrc && srcs.includes(bPhotoSrc) ? bPhotoSrc : null;
      let modelPhotoSrc = null;
      if (!photoSrc) {for (const s of srcs) {if (isCatalogFoodKitchen(recordForSource(s))) continue;let kind = 'photo';try {kind = await detectImageKind(s);} catch (e) {}const modelLike = await labelSourceAsModel(s);if (kind === 'photo') {photoSrc = s;break;}if (modelLike && !modelPhotoSrc) modelPhotoSrc = s;}}
      if (!photoSrc && modelPhotoSrc) photoSrc = modelPhotoSrc;
      let photo = null;
      if (photoSrc) {try {const im = await new Promise((res, rej) => {const i = new Image();i.onload = () => res(i);i.onerror = rej;i.src = photoSrc;});photo = { src: photoSrc, url: photoSrc, aspect: im.naturalWidth / Math.max(1, im.naturalHeight) };} catch (e) {}}
      // 인물누끼 분류: catCacheRef 캐시 또는 실시간 비전 분류. 모델 누끼는 좌측 클러스터 제외.
      const isPersonNukki = async (s) => {
        if (isCatalogFoodKitchen(recordForSource(s))) return false;
        let label = catCacheRef.current[s];
        if (label === undefined && (window.__mpClassify || (window.__bannerLLM && window.__bannerLLM.available()))) {
          try { label = await classifyVision(s, recordForSource(s)); catCacheRef.current[s] = label || null; } catch (e) { label = null; }
        }
        return label === '모델';
      };
      const nukki = [];
      for (const s of srcs) {
        if (cancelled) return;
        if (s === photoSrc) continue;                       // 화보는 좌측 클러스터에서 제외
        if (await isPersonNukki(s)) continue;               // 인물누끼는 화보/좌측 상품 클러스터 둘 다 금지
        try {const it = await window.AP550.prepareItem(s, bgMode);nukki.push({ ...it, id: 'bn' + nukki.length, src: s });} catch (e) {}
      }
      if (!cancelled) {setBNukki(nukki);setBPhoto(photo);setBBusy(false);}
    })();
    return () => {cancelled = true;setBBusy(false);};
  }, [srcs, bgMode, bannerType, bPhotoSrc, recordsBySrc]);

  // 자동 유형 라우팅(업로드/불러오기 공통): 화보+누끼 → B, 화보만 → C/D, 누끼만 → A.
  // 인물 화보는 투명 PNG여도 우측 화보 패널 후보로 취급한다.
  useSE2(() => {
    if (manualTypeRef.current) return;                           // 사용자 수동 선택은 자동 라우팅으로 덮지 않음
    if (bannerType === 'E') return;                              // [보험] E는 단일 결정 레이아웃 — 일러스트(사진)+인물누끼 공존이 정상이므로 B 자동전환 금지
    if (!srcs.length) return;
    let cancelled = false;
    (async () => {
      let photoN = 0, nukkiN = 0;
      for (const s of srcs) {
        let kind = 'photo';
        if (isCatalogFoodKitchen(recordForSource(s))) { nukkiN++; continue; }
        try {kind = await detectImageKind(s);} catch (e) {}
        const modelLike = await labelSourceAsModel(s);
        if (kind === 'photo' || modelLike) photoN++; else nukkiN++;
      }
      if (cancelled) return;
      if (photoN && nukkiN) { setBannerType('B'); setLayoutType('B'); }
      else if (photoN && !nukkiN) { const t = photoN >= 2 ? 'C' : 'D'; setBannerType(t); setLayoutType(t); }
      else if (nukkiN && !photoN) { setBannerType('A'); setLayoutType('A'); }
    })();
    return () => {cancelled = true;};
  }, [srcs, bannerType, recordsBySrc]);
  // 이미지 세트가 바뀌면 다음 자동 판별을 허용. 복원 중(pendingRestoreRef)에는 저장된 유형을 유지.
  useSE2(() => { if (pendingRestoreRef.current) return; manualTypeRef.current = false; }, [srcs]);

  // 비전 분류 (인물=모델 / 그 외=상품 카테고리)
  useSE2(() => {
    if (!allItems.length || !(window.__mpClassify || (window.__bannerLLM && window.__bannerLLM.available()))) return;
    const missing = allItems.filter((it) => it.src && !it.label && catCacheRef.current[it.src] === undefined);
    if (!missing.length) return;
    let cancelled = false;setCatBusy(true);
    (async () => {
      for (const it of missing) {if (cancelled) return;let l = null;try {l = await classifyVision(it.src, recordForSource(it.src));} catch (e) {l = null;}catCacheRef.current[it.src] = l || null;}
      if (cancelled) return;
      setAllItems((prev) => prev.map((it) => {const c = it.src ? catCacheRef.current[it.src] : null;return c && c !== it.label ? { ...it, label: c } : it;}));
      setCatBusy(false);
    })();
    return () => {cancelled = true;setCatBusy(false);};
  }, [allItems]);

  // 상품/모델 분리 (인물 라벨 = 모델)
  const productItems = useSM2(() => allItems.filter((it) => !isModelLabel(it.label)), [allItems]);
  const modelItems = useSM2(() => allItems.filter((it) => isModelLabel(it.label)).map((it) => {const mk = it.src ? headChinData[it.src] : null;const f = it.src ? faceData[it.src] : null;if (mk) return { ...it, headTopFrac: mk.head, faceHFrac: Math.max(0.05, mk.chin - mk.head), personKey: f && f.person };return f ? { ...it, headTopFrac: f.topFrac, faceHFrac: f.faceHFrac != null ? f.faceHFrac * 0.62 : null, personKey: f.person } : it;}), [allItems, faceData, headChinData]);
  const modelItem = useSM2(() => modelItems.length ? { ...modelItems[0], id: '__model' } : null, [modelItems]);
  // 패션 모델 얼굴(정수리·턱) · 인물 식별 감지 → 머리끜·턱끝선 정렬 & 동일 인물 그룹화
  useSE2(() => {
    const models = allItems.filter((it) => isModelLabel(it.label));
    if (!models.length) return;
    const missing = models.filter((it) => it.src && faceCacheRef.current[it.src] === undefined);
    if (!missing.length) return;
    let cancelled = false;
    (async () => {
      for (const it of missing) {
        if (cancelled) return;
        let geo = null,meta = null;
        try {geo = await analyzeModelHead(it.src);} catch (e) {geo = null;}
        try {meta = await detectFaceMeta(it.src);} catch (e) {meta = null;}
        faceCacheRef.current[it.src] = { topFrac: geo && geo.topFrac, faceHFrac: geo && geo.hsFrac, person: meta && meta.person };
      }
      if (cancelled) return;
      setFaceData((p) => {const n = { ...p };missing.forEach((it) => {n[it.src] = faceCacheRef.current[it.src];});return n;});
    })();
    return () => {cancelled = true;};
  }, [allItems]);

  // [task-011] 화보(사진) 피사체 검출 — 분할(C)/풀이미지(D). __mpHead(raw)로 원본 프레임 검출(자르면 x 틀어짐). 미검출→null(중앙 폴백).
  useSE2(() => {
    if (bannerType !== 'C' && bannerType !== 'D') return;
    if (!fullItems.length) return;
    const missing = fullItems.filter((it) => it.src && photoFaceCacheRef.current[it.src] === undefined);
    if (!missing.length) return;
    let cancelled = false;
    (async () => {
      for (const it of missing) {
        if (cancelled) return;
        let r = null;
        try { if (window.__mpHead) r = await window.__mpHead(it.src, { raw: true }); } catch (e) { r = null; }
        photoFaceCacheRef.current[it.src] = r ? { midXFrac: r.midXFrac, bodyMidXFrac: r.bodyMidXFrac, crownFrac: r.crownFrac != null ? r.crownFrac : r.topFrac, faceHFrac: r.faceHFrac, eyeFrac: r.eyeFrac, person: r.person } : null;
      }
      if (cancelled) return;
      setPhotoFace((p) => {const n = { ...p };missing.forEach((it) => {n[it.src] = photoFaceCacheRef.current[it.src];});return n;});
    })();
    return () => {cancelled = true;};
  }, [bannerType, fullItems]);

  // [SPLIT-FIX] C분할 콘텐츠 bbox 사전 크롭 — 흰 배경 상품·배경 없는 누끼 모두 대응.
  //   결과: splitContentCrops[src] = { url(크롭 or 원본), aspect(콘텐츠 비율), isCropped }.
  //   캐시 유지 → 이미 처리한 src는 재연산 안 함.
  useSE2(() => {
    if (bannerType !== 'C') return;
    if (!fullItems.length) return;
    const missing = fullItems.filter((it) => it.src && splitContentCropCacheRef.current[it.src] === undefined);
    if (!missing.length) return;
    let cancelled = false;
    (async () => {
      for (const it of missing) {
        if (cancelled) return;
        let r = null;
        try { r = await contentBboxCrop(it.src); } catch (e) { r = { url: it.src, aspect: it.aspect || 1, isCropped: false }; }
        splitContentCropCacheRef.current[it.src] = r;
      }
      if (cancelled) return;
      setSplitContentCrops((p) => {const n = { ...p };missing.forEach((it) => {n[it.src] = splitContentCropCacheRef.current[it.src];});return n;});
    })();
    return () => {cancelled = true;};
  }, [bannerType, fullItems]);

  // 카테고리 자동 (상품 라벨 다수결; 상품 없고 모델만이면 패션)
  useSE2(() => {
    if (!catAuto) return;
    // categoryRoot(GNB 루트) 우선 투표 — 카탈로그 메타가 있으면 정확한 루트로 분기
    const cnt = {};
    productItems.forEach((it) => {
      const gnb = it.categoryRoot;
      if (gnb && COMMERCE_CATS.includes(gnb)) { cnt[gnb] = (cnt[gnb] || 0) + 1; }
    });
    // GNB 루트 없으면 VISION_TO_COMMERCE 라벨 → 대표 GNB 루트(폴백)
    if (!Object.keys(cnt).length) {
      productItems.forEach((it) => {
        const bucket = VISION_TO_COMMERCE[it.label];
        const gnb = bucket && (BUCKET_TO_GNB[bucket] || bucket);
        if (gnb && COMMERCE_CATS.includes(gnb)) { cnt[gnb] = (cnt[gnb] || 0) + 1; }
      });
    }
    const keys = Object.keys(cnt);
    let top = keys.length ? keys.sort((a, b) => cnt[b] - cnt[a])[0] : null;
    if (!top && modelItems.length && !productItems.length) top = '여성패션';
    if (top && top !== category) setCategory(top);
  }, [allItems, catAuto]);

  // 개수 제한 (감지 후 적용) — 상품/모델 각각
  useSE2(() => {
    if (!category) return;
    const pCount = productItems.length,mCount = modelItems.length;
    // 상품+모델 혼합 패션 패턴은 업로드 수량 그대로 후보를 만들 수 있게 5개까지 유지.
    if (pCount >= 1 && mCount >= 1 && pCount + mCount <= 5) return;
    if (pCount > rule.prodMax || mCount > rule.modelMax) {
      let pSeen = 0,mSeen = 0;
      const keep = allItems.filter((it) => {
        if (isModelLabel(it.label)) {mSeen++;return mSeen <= rule.modelMax;}
        pSeen++;return pSeen <= rule.prodMax;
      }).map((it) => it.src);
      setSrcs((p) => p.filter((s) => keep.includes(s)));
      showToast(`${category} 제한 적용 — 상품 최대 ${rule.prodMax} · 모델 최대 ${rule.modelMax}`);
    }
  }, [category, allItems]);

  // 카테고리 드롭다운: 바깥 클릭 시 닫기
  useSE2(() => {
    if (!catOpen) return;
    const onDown = (e) => {if (catBoxRef.current && !catBoxRef.current.contains(e.target)) setCatOpen(false);};
    window.addEventListener('pointerdown', onDown);
    return () => window.removeEventListener('pointerdown', onDown);
  }, [catOpen]);

  // 로고 비율 로드 → 가로형(h20)/세로형(h60) 판정
  useSE2(() => {
    let cancelled = false;
    if (!logoSrcs.length) {setLogoMeta([]);return;}
    Promise.all(logoSrcs.map((u) => new Promise((res) => {const im = new Image();im.onload = () => res({ url: u, aspect: im.naturalWidth / Math.max(1, im.naturalHeight) });im.onerror = () => res({ url: u, aspect: 2 });im.src = u;}))).then((arr) => {
      if (cancelled) return;
      setLogoMeta((prev) => {
        const byUrl = {};
        (prev || []).forEach((g) => { if (g && g.url) byUrl[g.url] = g; });
        // Pending restores: prefer new per-axis arrays; fall back to legacy logoScales for both axes
        const pendingWs = pendingLogoScaleWsRef.current || pendingLogoScalesRef.current;
        const pendingHs = pendingLogoScaleHsRef.current || pendingLogoScalesRef.current;
        const next = arr.map((g, i) => {
          const prevG = byUrl[g.url];
          const orient = prevG && (prevG.orient === 'h' || prevG.orient === 'v') ? prevG.orient : (g.aspect >= 1.6 ? 'h' : 'v');
          let scaleW = 1, scaleH = 1;
          if (prevG && Number(prevG.scaleW) > 0) scaleW = Number(prevG.scaleW);
          else if (pendingWs && Number(pendingWs[i]) > 0) scaleW = Number(pendingWs[i]);
          if (prevG && Number(prevG.scaleH) > 0) scaleH = Number(prevG.scaleH);
          else if (pendingHs && Number(pendingHs[i]) > 0) scaleH = Number(pendingHs[i]);
          return { url: g.url, aspect: g.aspect, orient, scaleW, scaleH };
        });
        pendingLogoScalesRef.current = null;
        pendingLogoScaleWsRef.current = null;
        pendingLogoScaleHsRef.current = null;
        return next;
      });
      // Auto-select first logo when logos exist so sliders are immediately visible
      setSelLogoIdx((i) => {
        if (i != null && i < arr.length) return i;
        return arr.length > 0 ? 0 : null;
      });
    });
    return () => {cancelled = true;};
  }, [logoSrcs]);
  // 로고 가이드: 우측 상단 로고 높이만큼(+여백) 콘텐츠 상단을 예약 → 제품/모델과 겹침 방지
  // 로고 가이드 + 부가텍스트 고정위치: 우측 상단 데코 영역(로고 → 아래 30px → 텍스트 배지 90)만큼 상단을 예약
  const logoH = useSM2(() => logoRow550(logoMeta).rowH, [logoMeta]);
  const clampDecoScale = (value) => {
    const n = Number(value);
    return Number.isFinite(n) ? Math.max(0.5, Math.min(2.4, +n.toFixed(3))) : 1;
  };
  const withDecoScale = (base) => {
    if (!base) return null;
    const scale = clampDecoScale(decoScale);
    return { ...base, scale, baseW: base.w, baseH: base.h, w: base.w * scale, h: base.h * scale };
  };
  // 부가정보(택1) 고정위치 데코 — 캔버스 우측 상단(로고 아래 30px)에 렌더 & 예약 (최대 1개, 인증플래그만 최대 2)
  const deco = useSM2(() => {
    if (!exSel) return null;
    if (exSel === 'text') return withDecoScale(addText.trim() ? { kind: 'text', w: 90, h: 90, text: addText } : null);
    if (exSel === 'image') return withDecoScale({ kind: 'image', w: 130, h: 130, type: addImgType, title: addImgTitle, src: addImgSrc, imgKind: addImgKind, pos: addImgPos });
    if (exSel === 'flag') return withDecoScale({ kind: 'flag', w: 90, h: flagCount * 90 + (flagCount - 1) * 10, count: flagCount, items: normalizeFlagItems(flagItems).slice(0, flagCount) });
    if (exSel === 'mark' && markType) {const count = Math.min(3, Math.max(1, productItems.length - 1));return withDecoScale({ kind: 'mark', w: markType === '다수구성' ? 30 : 38, h: count * 30 + (count - 1) * 8, type: markType, count });}
    if (exSel === 'color') {if (!chipColors.length) return null;const shown = chipColors.length > 5 ? 5 : chipColors.length;return withDecoScale({ kind: 'color', w: 20, h: shown * 27 + (shown - 1) * 4, colors: chipColors });}
    return null;
  }, [exSel, addText, addImgType, addImgTitle, addImgSrc, flagCount, flagItems, chipColors, addImgKind, addImgPos, markType, productItems.length, decoScale]);
  const logoReserveTop = useSM2(() => logoH ? logoH + 14 : 0, [logoH]); // 로고만 예약(겹침 금지). 부가정보(이미지 표기 제외)는 모두 상품과 겹침 허용 → 예약 제외
  const mixedMultiModelPattern = useSM2(() => {
    const total = productItems.length + modelItems.length;
    if (layoutType !== 'A' || !productItems.length || modelItems.length <= 1 || total > 5) return false;
    return /패션|의류|언더웨어|스포츠/.test(String(category || ''));
  }, [layoutType, category, productItems.length, modelItems.length]);
  const nukkiPatternItems = useSM2(() => mixedMultiModelPattern ? allItems.slice() : productItems, [mixedMultiModelPattern, allItems, productItems]);
  const candidates = useSM2(() => {
    if (!(layoutType === 'A' && nukkiPatternItems.length)) return [];
    const sc = { ...scorer, logoReserveTop, category };
    const base = window.AP550.generateCandidates(nukkiPatternItems, mixedMultiModelPattern ? null : modelItem, sc) || [];
    // HARD RULE: 상품 누끼 ≥1 + 인물 누끼 ≥1 → 인물은 항상 우측. modelCorner 후보 생성.
    //   인물이 1명이든 여러 명이든 동일 규칙(첫 번째 인물이 우측 밴드/우하단 앵커).
    if (!mixedMultiModelPattern && modelItems.length >= 1 && window.AP550.generateModelCornerCandidates) {
      const corner = window.AP550.generateModelCornerCandidates(modelItems, productItems, { ...sc, category }) || [];
      return base.concat(corner);
    }
    return base;
  }, [layoutType, nukkiPatternItems, mixedMultiModelPattern, productItems, modelItems, modelItem, scorer, category]);
  const ranked = useSM2(() => {
    if (!candidates.length) return restoredCand && restoredCand._preservedLayout !== 'B' ? [restoredCand] : [];
    // 인물 배치 규칙(model-corner) 후보를 1순위로 노출 — band 후보와 함께 rankCandidates 에 넣으면
    //   랭킹·slice(0,14)에 밀려 안 보이던 문제 해결. corner/band 를 분리 랭킹(각자 slice) 후 corner 를 앞에 둔다(band 는 뒤에 유지 → 다양성).
    const corner = candidates.filter((c) => c.strategy === 'model-corner');
    const rest = candidates.filter((c) => c.strategy !== 'model-corner');
    // corner 후보는 엔진에서 이미 알맞은 랭커로 정렬됨(상품=A랭커·패션=세로타워 랭커) → 여기서 재정렬 X (재정렬하면 세로타워가 실루엣 필터에 걸러짐). 그대로 앞에.
    const rankedRest = rest.length ? window.AP550.rankCandidates(rest, scorer) : [];
    let base = rankAccessoryCenteredCandidates(corner.concat(rankedRest), category, productItems.length, modelItems.length);
    if (/식품|주방|식품\/주방/.test(String(category || '')) && productItems.length >= 2) {
      const grouped = buildFoodKitchenGroupedCandidate(productItems, (base[0] && base[0].bounds) || { x: 50, y: 40, w: 450, h: 470 });
      if (grouped) {
        const metrics = window.AP550.computeMetrics ? window.AP550.computeMetrics(grouped.rects, grouped.bounds) : {};
        const scored = { ...grouped, metrics, scoring: { score: 999 } };
        base = [scored].concat(base.filter((cand) => cand.id !== scored.id));
      }
    }
    if (!restoredCand || restoredCand._preservedLayout === 'B') return base;
    return [restoredCand].concat(base.filter((cand) => cand.id !== restoredCand.id));
  }, [candidates, scorer, restoredCand, category, productItems, productItems.length, modelItems.length]);
  const primaryId = pinnedId || ranked[0] && ranked[0].id;
  const primary = ranked.find((c) => c.id === primaryId) || ranked[0];
  const secondary = ranked.find((c) => c.id !== primaryId);
  // 패션(의류·모델) 누끼형: 상품 없이 모델만 → 모델 가로 배치 (머리 상단 정렬 · 가장자리 얼굴 세이프 안 · 하단 풀블리드)
  const fashionCands = useSM2(() => {
    const base = layoutType === 'A' && !productItems.length && modelItems.length ? window.AP550.generateFashionCandidates(modelItems, { ...scorer, logoReserveTop }) : [];
    if (!restoredCand || restoredCand._preservedLayout === 'B') return base;
    return [restoredCand].concat(base.filter((cand) => cand.id !== restoredCand.id));
  }, [layoutType, productItems, modelItems, scorer, restoredCand]);
  const fashionPrimaryId = pinnedId && fashionCands.some((c) => c.id === pinnedId) ? pinnedId : fashionCands[0] && fashionCands[0].id;
  const fashionCand = fashionCands.find((c) => c.id === fashionPrimaryId) || fashionCands[0] || null;
  // D 풀이미지형 후보 (화보 × 초점 프리셋). 초점은 fullFocals로 사용자 조정 가능.
  // [task-011 D] 풀이미지 인물 → 머리끝 상단 정렬(가로 구도 유지=중앙). 비인물/세로여유 없음 → 엔진 프리셋(중앙). 550 정사각(550×550).
  const fullPhotos = useSM2(() => fullItems.map((it) => {
    const f = it.src && photoFace[it.src];
    if (!f || f.crownFrac == null) return it;
    const a = it.aspect || 1, CW = 550, CH = 550, TOP = 0.12;
    const shf = CH / Math.max(CW / a, CH);
    const fy = (1 - shf) > 1e-4 ? (f.crownFrac - TOP * shf) / (1 - shf) : 0.5;
    return { ...it, focal: { x: 50, y: Math.max(0, Math.min(100, fy * 100)) } };
  }), [fullItems, photoFace]);
  const fullCands = useSM2(() => layoutType === 'D' && fullPhotos.length ? window.AP550.generateFullCandidates(fullPhotos, { logoReserveTop }) : [], [layoutType, fullPhotos]);
  const fullPrimaryId = fullPinned && fullCands.some((c) => c.id === fullPinned) ? fullPinned : fullCands[0] && fullCands[0].id;
  const fullPrimary = fullCands.find((c) => c.id === fullPrimaryId) || fullCands[0] || null;
  const fullSecondary = fullCands.find((c) => c.id !== (fullPrimary && fullPrimary.id)) || null;
  const fullFocalOf = (c) => c ? fullFocals[c.id] || c.focal || { x: 50, y: 50 } : { x: 50, y: 50 };
  const setFullFocal = (id, f) => setFullFocals((p) => ({ ...p, [id]: f }));
  const renderFullBlob = async (cand, focal) => {
    if (!cand) return null;
    const im = await _loadImgD(cand.src || cand.url);
    const cv = document.createElement('canvas');cv.width = 550;cv.height = 550;
    const ctx = cv.getContext('2d');ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 550, 550);
    const sr = window.AP550.fullSourceRect(im.naturalWidth, im.naturalHeight, focal);
    drawAdjusted(ctx, im, adjustmentFor(cand.src || cand.url), sr.sx, sr.sy, sr.sw, sr.sh, 0, 0, 550, 550);
    if (window.BannerOverlay) await window.BannerOverlay.drawStandard(ctx, { logos: logoMeta, deco, decoPos }, { imgTypes: ADD_IMG_TYPES });
    return await new Promise((res) => cv.toBlob(res, 'image/jpeg', 1.0));
  };
  // [SPLIT-FIX] C 분할형 사진 준비 — contentBboxCrop 결과(흰배경 상품 크롭 or 원본)를 url·contentAspect로 주입.
  //   콘텐츠 비율(contentAspect)이 엔진에서 패널 폭 산정의 근거가 됨.
  //   인물 감지(photoFace)가 있으면 focal 중심도 주입(줌 없음 — 패널 높이 균등화로 크기 맞춤).
  //   HARD RULE: isModel=true 로 마킹된 사진은 엔진에서 항상 맨 오른쪽 패널에 배치.
  const splitPhotos = useSM2(() => fullItems.map((it) => {
    const crop = it.src && splitContentCrops[it.src];
    const f = it.src && photoFace[it.src];
    const focal = f && f.midXFrac != null ? { x: Math.max(0, Math.min(100, f.midXFrac * 100)), y: Math.max(0, Math.min(100, ((f.crownFrac || 0) + (f.faceHFrac || 0.1) * 0.5) * 100)) } : null;
    return {
      ...it,
      url: (crop && crop.url) || it.url || it.src,
      contentAspect: crop ? crop.aspect : null,
      isModel: !!(f && f.crownFrac != null), // 얼굴/정수리 감지 → 모델 패널(항상 최우측)
      ...(focal ? { focal } : {})
    };
  }), [fullItems, splitContentCrops, photoFace]);
  // [SPLIT-FIX] alignSplitFaces 제거 — 패널 높이 균등화(specH)로 피사체 크기를 맞추므로 zoom 강제 불필요.
  //   zoom을 강제하면 contain 패널을 cover로 잘라 내부가 일부 가려지므로 사용 안 함.
  const splitCands = useSM2(() => layoutType === 'C' && splitPhotos.length >= 2 ? window.AP550.generateSplitCandidates(splitPhotos, {}) : [], [layoutType, splitPhotos]);
  const splitUploadOrderCand = useSM2(() => {
    if (layoutType !== 'C' || !splitCands.length) return null;
    const order = splitPhotos.slice(0, 4).map((p) => p.src);
    return splitCands.find((c) => c.panels && c.panels.length === order.length && c.panels.every((pn, i) => pn.src === order[i])) || null;
  }, [layoutType, splitCands, splitPhotos]);
  useSE2(() => {
    if (!pendingSplitOrderRef.current || !splitUploadOrderCand) return;
    setSplitPinned(splitUploadOrderCand.id);
    pendingSplitOrderRef.current = false;
  }, [splitUploadOrderCand]);
  const splitPrimaryId = splitPinned && splitCands.some((c) => c.id === splitPinned) ? splitPinned : splitCands[0] && splitCands[0].id;
  const applySplitPanelOrder = (cand) => {
    if (!cand || !Array.isArray(cand.panels)) return cand;
    const order = splitPanelOrders[cand.id];
    if (!Array.isArray(order) || order.length !== cand.panels.length) return cand;
    const used = {};
    let changed = false;
    const panels = cand.panels.map((slot, i) => {
      const wanted = order[i];
      let srcIdx = -1;
      for (let k = 0; k < cand.panels.length; k++) {
        if (!used[k] && cand.panels[k].src === wanted) { srcIdx = k; break; }
      }
      if (srcIdx < 0) {
        for (let k = 0; k < cand.panels.length; k++) { if (!used[k]) { srcIdx = k; break; } }
      }
      used[srcIdx] = true;
      const img = cand.panels[srcIdx] || slot;
      if (srcIdx !== i) changed = true;
      return { ...slot, src: img.src, url: img.url, focal: img.focal, aspect: img.aspect, contentAspect: img.contentAspect };
    });
    return changed ? { ...cand, panels, rowsLabel: (cand.rowsLabel || '') + ' · 수동 배치' } : cand;
  };
  const splitPrimaryBase = splitCands.find((c) => c.id === splitPrimaryId) || splitCands[0] || null;
  const splitPrimary = applySplitPanelOrder(splitPrimaryBase);
  const splitSecondary = applySplitPanelOrder(splitCands.find((c) => c.id !== (splitPrimaryBase && splitPrimaryBase.id)) || null);
  const splitActiveIndex = splitCands.findIndex((c) => c.id === splitPrimaryId);
  const pickSplitOffset = (d) => {
    if (!splitCands.length) return;
    const i = splitActiveIndex >= 0 ? splitActiveIndex : 0;
    const n = (i + d + splitCands.length) % splitCands.length;
    setSplitPinned(splitCands[n].id);
  };
  const swapSplitPanels = (candId, from, to) => {
    if (!candId || from === to) return;
    const base = splitCands.find((c) => c.id === candId);
    if (!base || !Array.isArray(base.panels) || !base.panels[from] || !base.panels[to]) return;
    setSplitPanelOrders((p) => {
      const cur = Array.isArray(p[candId]) && p[candId].length === base.panels.length ? p[candId].slice() : base.panels.map((pn) => pn.src);
      const t = cur[from]; cur[from] = cur[to]; cur[to] = t;
      return { ...p, [candId]: cur };
    });
    setSplitFocals((p) => {
      const a = candId + ':' + from, b = candId + ':' + to;
      const n = { ...p }, av = n[a], bv = n[b];
      if (bv) n[a] = bv; else delete n[a];
      if (av) n[b] = av; else delete n[b];
      return n;
    });
    setSplitPinned(candId);
    dirtyRef.current = true;
    setSavedBtn(false);
  };
  const splitFocalOf = (candId, panelIdx, base) => splitFocals[candId + ':' + panelIdx] || base || { x: 50, y: 50 };
  const setSplitFocal = (candId, panelIdx, f) => setSplitFocals((p) => ({ ...p, [candId + ':' + panelIdx]: f }));
  // 패널 하나를 초점 기준으로 cover 크롭(원본→패널 사각). 미리보기 objectPosition과 동일 결과.
  const coverCrop = (natW, natH, dw, dh, f) => {const z = f && f.zoom ? f.zoom : 1;const scale = Math.max(dw / natW, dh / natH);const sw = dw / scale / z, sh = dh / scale / z;const fx = (f && f.x != null ? f.x : 50) / 100, fy = (f && f.y != null ? f.y : 50) / 100;return { sx: Math.max(0, Math.min(natW - sw, (natW - sw) * fx)), sy: Math.max(0, Math.min(natH - sh, (natH - sh) * fy)), sw, sh };};
  // [SPLIT-FIX] renderSplitBlob: pn.url = contentBboxCrop 결과(흰배경 크롭 or 원본).
  //   패널 크기(pn.w×pn.h)가 이미 콘텐츠 비율과 일치 → coverCrop(zoom=1) = 전체 이미지 드로우(크롭 없음).
  //   사용자가 수동 zoom(wheel)을 적용한 경우 coverCrop이 그대로 zoom 영역을 잘라 줌.
  const renderSplitBlob = async (cand) => {
    if (!cand) return null;
    const cv = document.createElement('canvas');cv.width = 550;cv.height = 550;
    const ctx = cv.getContext('2d');ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 550, 550);
    for (let i = 0; i < cand.panels.length; i++) {
      const pn = cand.panels[i];
      try {const im = await _loadImgD(pn.url || pn.src);const f = splitFocalOf(cand.id, i, pn.focal);const c = coverCrop(im.naturalWidth, im.naturalHeight, pn.w, pn.h, f);drawAdjusted(ctx, im, adjustmentFor(pn.src || pn.url), c.sx, c.sy, c.sw, c.sh, pn.x, pn.y, pn.w, pn.h);} catch (e) {}
    }
    if (window.BannerOverlay) await window.BannerOverlay.drawStandard(ctx, { logos: logoMeta, deco, decoPos }, { imgTypes: ADD_IMG_TYPES });
    return await new Promise((res) => cv.toBlob(res, 'image/jpeg', 1.0));
  };
  // B 분할+누끼형 후보 — 좌 3/5 영역에 A 누끼 클러스터(다양성 상속), 우 2/5 화보 패널.
  // 분할 지점 하나(B_SPLIT)에서 좌 누끼 영역·우 화보 패널을 파생 → 비례 바꿀 땐 이 값 하나만 고치면 전부 동기화(하드코딩 흩어짐 제거).
  const B_SPLIT = 330;                                    // 좌 누끼 : 우 화보 분할 X (3/5=330)
  const B_RMARGIN = 50;                                   // 누끼 영역 오른쪽 여백 = 왼쪽 세이프(50)와 대칭(누끼만, 컨텐츠 전체 세이프는 불변)
  const B_LEFT = { x: 50, y: 40, w: B_SPLIT - 50 - B_RMARGIN, h: 470 }; // 좌측 누끼 영역(좌우 여백 대칭 50)
  const B_PHOTO = { x: B_SPLIT, y: 0, w: 550 - B_SPLIT, h: 550 };   // 우측 화보 패널(풀블리드)
  const bCands = useSM2(() => {
    const base = layoutType === 'B' && bNukki.length ? window.AP550.generateSplitNukkiCandidates(bNukki, { ...scorer, region: B_LEFT, allowTower: true }) : [];
    if (!restoredCand || restoredCand._preservedLayout !== 'B') return base;
    return [restoredCand].concat(base.filter((cand) => cand.id !== restoredCand.id));
  }, [layoutType, bNukki, scorer, restoredCand]);
  const bPrimaryId = bPinned && bCands.some((c) => c.id === bPinned) ? bPinned : bCands[0] && bCands[0].id;
  const bPrimary = bCands.find((c) => c.id === bPrimaryId) || bCands[0] || null;
  const bSecondary = bCands.find((c) => c.id !== (bPrimary && bPrimary.id)) || null;
  const bFocalOf = () => {
    const z = bFocal && bFocal.zoom != null ? Number(bFocal.zoom) : 1;
    return { x: 50, y: 50, zoom: Math.max(1, Math.min(3, Number.isFinite(z) ? z : 1)) };
  };
  const renderBBlob = async (cand, photo, focal) => {
    if (!cand) return null;
    const cv = document.createElement('canvas');cv.width = 550;cv.height = 550;
    const ctx = cv.getContext('2d');ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 550, 550);
    const pf = B_PHOTO;
    if (photo) {try {const im = await _loadImgD(photo.src || photo.url);const c = coverCrop(im.naturalWidth, im.naturalHeight, pf.w, pf.h, focal);drawAdjusted(ctx, im, adjustmentFor(photo.src || photo.url), c.sx, c.sy, c.sw, c.sh, pf.x, pf.y, pf.w, pf.h);} catch (e) {}}
    const ordered = [...rectsFor(cand)].filter((r) => r.role !== 'model').sort((a, b) => a.z - b.z);
    for (const r of ordered) {try {const im = await _loadImgD(r.url);drawAdjusted(ctx, im, adjustmentFor(r.src || r.url), r.x, r.y, r.w, r.h);} catch (e) {}}
    if (window.BannerOverlay) await window.BannerOverlay.drawStandard(ctx, { logos: logoMeta, deco, decoPos }, { imgTypes: ADD_IMG_TYPES });
    return await new Promise((res) => cv.toBlob(res, 'image/jpeg', 1.0));
  };
  // E 보험 — 모델(우측, 지정 시) / 일러스트(나머지 첫 장). 로고+보험명 좌상단, 부가정보 좌하단.
  const eModel = useSM2(() => layoutType === 'E' && eModelSrc ? fullItems.find((it) => it.src === eModelSrc) || null : null, [layoutType, eModelSrc, fullItems]);
  const eIllust = useSM2(() => layoutType === 'E' ? fullItems.find((it) => it.src !== eModelSrc) || null : null, [layoutType, eModelSrc, fullItems]);
  const eReady = layoutType === 'E' && (eName.trim() || eIllust || eModel || logoMeta.length);
  // [보험 배경] 일러스트(투명) → contain(전체 맞춤·안 잘림) / 화보(불투명) → cover(꽉 채움). 투명도(detectImageKind)로 자동 판별.
  useSE2(() => {
    if (bannerType !== 'E' || !eIllust || !eIllust.src) { setEIllustContain(false); return; }
    const src = eIllust.src;let cancelled = false;
    (async () => {
      let kind = eIllustKindRef.current[src];
      if (kind === undefined) { try { kind = await detectImageKind(src); } catch (e) { kind = 'photo'; } eIllustKindRef.current[src] = kind; }
      if (!cancelled) setEIllustContain(kind === 'nukki');
    })();
    return () => {cancelled = true;};
  }, [bannerType, eIllust && eIllust.src]);
  // 보험 텍스트 색 = 로고 대표색(밝으면 살짝 어둡게). 배경모드 무관.
  useSE2(() => {
    if (bannerType !== 'E') { setETextColor(null); return; }
    const lg = logoMeta[0];
    if (!lg || !lg.url) { setETextColor(null); return; }
    let cancelled = false;
    (async () => {
      let hex = null; try { hex = await extractDominantColor(lg.url); } catch (e) {}
      if (cancelled) return;
      const m = hex && /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
      if (!m) { setETextColor(null); return; }
      const r = parseInt(m[1], 16), g = parseInt(m[2], 16), b = parseInt(m[3], 16);
      const hx = (a, c, d) => '#' + [a, c, d].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
      const lum = 0.299 * r + 0.587 * g + 0.114 * b, k = lum > 170 ? 150 / lum : 1;
      setETextColor(hx(r * k, g * k, b * k));
    })();
    return () => { cancelled = true; };
  }, [bannerType, logoMeta]);
  // 배경색: 기본 = E_CFG.bgDefault(레퍼런스 실측) 고정. 컬러칩/커스텀(eBgChip) 지정 시 그걸 우선.
  const eBg = layoutType === 'E' ? (eBgChip || E_CFG.bgDefault) : '#fff';
  const eBgStyle = eBg;
  const eText = (layoutType === 'E' && eTextColor) ? eTextColor : '#1c2b4a';
  const eFocalOf = () => eFocal || { x: 50, y: 50 };
  // B~E 초점 편집 되돌리기/다시실행/초기화 (A 편집 버튼과 동일 UX). 초점 상태 전체를 스냅샷.
  const editSeqRef = React.useRef(0);
  const [focalHist, setFocalHist] = useS2([]);
  const [focalRedo, setFocalRedo] = useS2([]);
  const snapFocals = () => ({ b: bFocal, s: { ...splitFocals }, splitPanelOrders: { ...splitPanelOrders }, f: { ...fullFocals }, e: eFocal });
  const restoreFocals = (o) => {setBFocal(o.b);setSplitFocals(o.s);setSplitPanelOrders(o.splitPanelOrders || {});setFullFocals(o.f);setEFocal(o.e);};
  const focalBegin = () => {const snap = { ...snapFocals(), _editSeq: ++editSeqRef.current };setFocalHist((h) => [...h.slice(-40), snap]);setFocalRedo([]);};
  const focalUndo = () => {if (!focalHist.length) return;const prev = focalHist[focalHist.length - 1];setFocalRedo((r) => [...r, { ...snapFocals(), _editSeq: prev._editSeq || 0 }]);restoreFocals(prev);setFocalHist((h) => h.slice(0, -1));};
  const focalRedoDo = () => {if (!focalRedo.length) return;const nx = focalRedo[focalRedo.length - 1];setFocalHist((h) => [...h, { ...snapFocals(), _editSeq: nx._editSeq || 0 }]);restoreFocals(nx);setFocalRedo((r) => r.slice(0, -1));};
  const focalReset = () => {focalBegin();if (bannerType === 'B') setBFocal(null);else if (bannerType === 'C' && splitPrimary) {setSplitFocals((p) => {const n = { ...p };Object.keys(n).forEach((k) => {if (k.indexOf(splitPrimary.id + ':') === 0) delete n[k];});return n;});setSplitPanelOrders((p) => {const n = { ...p };delete n[splitPrimary.id];return n;});}else if (bannerType === 'D' && fullPrimary) setFullFocals((p) => {const n = { ...p };delete n[fullPrimary.id];return n;});else if (bannerType === 'E') setEFocal(null);};
  const focalEdit = { onUndo: focalUndo, onRedo: focalRedoDo, onReset: focalReset, canUndo: focalHist.length > 0, canRedo: focalRedo.length > 0 };
  const renderEBlob = async () => {
    const _nl = String(eName || '').split(String.fromCharCode(10)).map((s) => s.trim()).filter(Boolean).length;
    const L = eLayout({ logoMeta, illustAspect: eIllust && eIllust.aspect, illustContain: eIllustContain, nameLines: _nl, hasModel: !!eModel, modelAspect: eModel && eModel.aspect, deco });
    const cv = document.createElement('canvas');cv.width = 550;cv.height = 550;
    const ctx = cv.getContext('2d');ctx.fillStyle = eBg;ctx.fillRect(0, 0, 550, 550); // [보험 배경] 기본색 또는 컬러칩/커스텀
    const ef = eFocalOf();
    const illustBox = eScaledBox(L.illust, ef.illustZoom, 'center-bottom');
    const modelBox = eScaledBox(L.model, ef.modelZoom, 'right-bottom');
    const modelFront = ef.modelFront !== false;
    const drawEIllust = async () => {
      if (!eIllust || !illustBox) return;
      try {const im = await _loadImgD(eIllust.url);
        if (illustBox.contain) { const s = Math.min(illustBox.w / im.naturalWidth, illustBox.h / im.naturalHeight);const dw = im.naturalWidth * s, dh = im.naturalHeight * s;const dy = illustBox.anchorBottom ? (illustBox.y + illustBox.h - dh) : (illustBox.y + (illustBox.h - dh) / 2);drawAdjusted(ctx, im, adjustmentFor(eIllust.src || eIllust.url), illustBox.x + (illustBox.w - dw) / 2, dy, dw, dh); }
        else { const c = coverCrop(im.naturalWidth, im.naturalHeight, illustBox.w, illustBox.h, { x: 50, y: 50 });drawAdjusted(ctx, im, adjustmentFor(eIllust.src || eIllust.url), c.sx, c.sy, c.sw, c.sh, illustBox.x, illustBox.y, illustBox.w, illustBox.h); }
      } catch (e) {}
    };
    const drawEModel = async () => {
      if (!eModel || !modelBox) return;
      try {const im = await _loadImgD(eModel.url);drawAdjusted(ctx, im, adjustmentFor(eModel.src || eModel.url), modelBox.x, modelBox.y, modelBox.w, modelBox.h);} catch (e) {}
    };
    if (!modelFront) await drawEModel();
    await drawEIllust();
    for (const lg of L.logos) {try {const im = await _loadImgD(lg.url);ctx.drawImage(im, lg.x, lg.y, lg.w, lg.h);} catch (e) {}}
    const lines = String(eName || '').split(String.fromCharCode(10)).map((s) => s.trim()).filter(Boolean);
    if (lines.length) {ctx.fillStyle = eText;ctx.textBaseline = 'top';ctx.textAlign = L.nameCenter ? 'center' : 'left';const tx = L.nameCenter ? (L.nameX + L.nameRight) / 2 : L.nameX;const fs = L.nameFont || 26, lh = fs * 1.2;try { ctx.letterSpacing = (fs * -0.025) + 'px'; } catch (e) {}lines.forEach((ln, i) => {ctx.font = '700 ' + fs + 'px "Noto Sans KR", "Noto Sans CJK KR", sans-serif';ctx.fillText(ln, tx, L.nameY + i * lh);});ctx.textAlign = 'left';try { ctx.letterSpacing = '0px'; } catch (e) {}}
    if (modelFront) await drawEModel();
    // 부가정보(deco) — E 기본은 좌하단, 사용자가 이동하면 저장 이미지에도 같은 위치 반영.
    if (deco && L.decoBox && window.BannerOverlay) {
      const px = decoPos && typeof decoPos === 'object' && decoPos.x != null ? Math.max(0, Math.min(550 - (deco.w || 90), Number(decoPos.x) || 0)) : L.decoBox.x;
      const py = decoPos && typeof decoPos === 'object' && decoPos.y != null ? Math.max(0, Math.min(550 - (deco.h || 90), Number(decoPos.y) || 0)) : L.decoBox.y;
      await window.BannerOverlay.drawDeco(ctx, deco, px, py, { imgTypes: ADD_IMG_TYPES });
    }
    return await new Promise((res) => cv.toBlob(res, 'image/jpeg', 1.0));
  };
  const fashionDownload = async () => {if (!fashionCand) return;const cv = document.createElement('canvas');cv.width = 550;cv.height = 550;const ctx = cv.getContext('2d');ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 550, 550);const ordered = [...fashionCand.rects].sort((a, b) => a.z - b.z);for (const r of ordered) {try {const im = await _loadImgD(r.url);drawAdjusted(ctx, im, adjustmentFor(r.src || r.url), r.x, r.y, r.w, r.h);} catch (e) {}}const a = document.createElement('a');a.href = cv.toDataURL('image/png');a.download = '패션모델_누끼.png';a.click();showToast('PNG를 다운로드했습니다 (550×550)');};

  // ── 제품 오브젝트 편집(이동·리사이즈·z순서·undo/redo) — A 누끼형 1순위 카드에서 동작 ──
  const rankOf = (id) => ranked.findIndex((c) => c.id === id) + 1;
  const rectsFor = (c) => (c && editRects[c.id]) || (c && patLock && patLock.id === c.id && patLock.rects) || (c && c.rects) || [];
  const metricsFor = (c) => c && editRects[c.id] ? window.AP550.computeMetrics(editRects[c.id], c && c.bounds) : c ? c.metrics : {};
  const editCand = layoutType === 'B' ? bPrimary : fashionCand || primary; // 편집 대상 — B는 좌측 누끼 클러스터(A 엔진), 패션은 패션 후보, 그 외 1순위 제품
  const adjustmentFor = (source) => window.BannerImageTools && window.BannerImageTools.forSource
    ? window.BannerImageTools.forSource(imageAdjust, imageAdjustBySource, source)
    : imageAdjust;
  const selectedAdjustmentRects = editCand && rectsFor(editCand).filter((r) => r && selectedIds.indexOf(r.id) >= 0) || [];
  const activeImageAdjust = selectedAdjustmentRects.length ? adjustmentFor(selectedAdjustmentRects[0].src || selectedAdjustmentRects[0].url) : imageAdjust;
  const applyImageAdjust = (next) => {
    const sources = selectedAdjustmentRects.map((r) => r.src || r.url).filter(Boolean);
    if (!sources.length) { setImageAdjust(next); return; }
    setImageAdjustBySource((prev) => {
      const n = { ...prev };
      sources.forEach((source) => { n[source] = { ...next }; });
      return n;
    });
  };
  const selectedOutpaintRect = editCand && rectsFor(editCand).find((rect) => rect && rect.id === selectedId);
  const selectedOutpaintItem = selectedOutpaintRect && (allItems.find((item) => item && (item.id === selectedOutpaintRect.id || item.src === selectedOutpaintRect.src || item.url === selectedOutpaintRect.url)) || bNukki.find((item) => item && (item.id === selectedOutpaintRect.id || item.src === selectedOutpaintRect.src || item.url === selectedOutpaintRect.url)));
  const outpaintSource = layoutType === 'D' ? fullPrimary && (fullPrimary.src || fullPrimary.url)
    : layoutType === 'C' ? splitPrimary && splitPrimary.panels && splitPrimary.panels[0] && (splitPrimary.panels[0].src || splitPrimary.panels[0].url)
    : layoutType === 'B' ? bPhoto && (bPhoto.src || bPhoto.url) || selectedOutpaintItem && selectedOutpaintItem.src
    : layoutType === 'E' ? eIllust && (eIllust.src || eIllust.url) || eModel && (eModel.src || eModel.url)
    : selectedOutpaintItem && selectedOutpaintItem.src || srcs[0] || null;
  const applyOutpaintVariant = (nextSrc) => {
    const oldSrc = outpaintSource;
    if (!oldSrc || !nextSrc) return;
    const cand = editCand;
    if (cand) pendingSourceEditRef.current = { cand: { ...cand }, rects: rectsFor(cand).map((rect) => ({ ...rect })), oldKeys: Array.from(sourceKeys([oldSrc])), nextSrc, layoutType };
    preserveSrcChangeRef.current = true;
    setSrcs((prev) => prev.map((src) => src === oldSrc ? nextSrc : src));
    setNukkiSel((prev) => prev.map((src) => src === oldSrc ? nextSrc : src));
    if (bPhotoSrc === oldSrc) setBPhotoSrc(nextSrc);
    if (eModelSrc === oldSrc) setEModelSrc(nextSrc);
    transferSourceMeta(oldSrc, nextSrc);
    if (bannerType === 'C') pendingSplitOrderRef.current = true;
    showToast('선택한 AI 배경 생성 시안을 적용했습니다');
  };
  const preserveCurrentPlacementForOverlay = () => {const cand = editCand;if (!cand || pinnedId) return;setPinnedId(cand.id);};
  const setDecoPosPreserved = (next) => {preserveCurrentPlacementForOverlay();setDecoPos(next);};
  const setDecoScalePreserved = (next) => {preserveCurrentPlacementForOverlay();setDecoScale(clampDecoScale(next));setDecoSelected(true);setSelLogoIdx(null);setSelectedId(null);};
  const setAddImgPosPreserved = (next) => {preserveCurrentPlacementForOverlay();setAddImgPos(next);};
  const beginEdit = () => {if (!editCand) return;preserveCurrentPlacementForOverlay();pendingRef.current = { id: editCand.id, rects: rectsFor(editCand), _editSeq: ++editSeqRef.current };}; // 제스처 스냅샷(실변화 시 커밋)
  const onPrimaryChange = (next) => {if (!editCand) return;preserveCurrentPlacementForOverlay();if (pendingRef.current) {const snap = pendingRef.current;pendingRef.current = null;setHistory((h) => [...h.slice(-60), snap]);setRedo([]);}setEditRects((p) => ({ ...p, [editCand.id]: next }));};
  const undo = () => {if (!editCand || !history.length) return;const last = history[history.length - 1];setRedo((r) => [...r, { id: editCand.id, rects: rectsFor(editCand), _editSeq: last._editSeq || 0 }]);setEditRects((p) => ({ ...p, [last.id]: last.rects }));setHistory((h) => h.slice(0, -1));setSelectedId(null);};
  const redo = () => {if (!editCand || !redoList.length) return;const nx = redoList[redoList.length - 1];setHistory((h) => [...h, { id: editCand.id, rects: rectsFor(editCand), _editSeq: nx._editSeq || 0 }]);setEditRects((p) => ({ ...p, [nx.id]: nx.rects }));setRedo((r) => r.slice(0, -1));setSelectedId(null);};
  const bUndo = () => {
    const rectSeq = history.length ? history[history.length - 1]._editSeq || 0 : -1;
    const focalSeq = focalHist.length ? focalHist[focalHist.length - 1]._editSeq || 0 : -1;
    if (focalSeq >= rectSeq && focalHist.length) focalUndo(); else undo();
  };
  const bRedo = () => {
    const rectSeq = redoList.length ? redoList[redoList.length - 1]._editSeq || 0 : Infinity;
    const focalSeq = focalRedo.length ? focalRedo[focalRedo.length - 1]._editSeq || 0 : Infinity;
    if (focalSeq <= rectSeq && focalRedo.length) focalRedoDo(); else redo();
  };
  undoRef.current = undo;redoRef.current = redo;
  const resetPrimary = () => {if (!editCand) return;setEditRects((p) => {const n = { ...p };delete n[editCand.id];return n;});setPatLock(null);setSelectedId(null);};
  // 배치 초기화 통합 핸들러 — 제품 rects(A/B) + 화보 초점(B/C/D/E) 모두 생성 시 상태로 복원
  const resetAll = () => {
    if (editCand) {
      setEditRects((p) => {const n = { ...p };delete n[editCand.id];return n;});
      setPatLock(null);setSelectedId(null);
      setHistory([]);setRedo([]);
    }
    if (bannerType === 'B') { setBFocal(null); }
    else if (bannerType === 'C' && splitPrimary) { setSplitFocals((p) => {const n = { ...p };Object.keys(n).forEach((k) => {if (k.indexOf(splitPrimary.id + ':') === 0) delete n[k];});return n;});setSplitPanelOrders((p) => {const n = { ...p };delete n[splitPrimary.id];return n;}); }
    else if (bannerType === 'D' && fullPrimary) { setFullFocals((p) => {const n = { ...p };delete n[fullPrimary.id];return n;}); }
    else if (bannerType === 'E') { setEFocal(null); }
    setFocalHist([]);setFocalRedo([]);
  };
  const defaultAddImgGuidePos = () => ({ x: 50, y: 50, zoom: 1 });
  const addImgGuideMoved = () => {
    const p = addImgPos || {};
    return Math.abs((Number(p.x) || 50) - 50) > 0.01 || Math.abs((Number(p.y) || 50) - 50) > 0.01 || Math.abs((Number(p.zoom) || 1) - 1) > 0.01;
  };
  const resetOverlayGuide = () => {
    setDecoPos(null);
    setDecoScale(1);
    setDecoSelected(false);
    setAddImgPos(defaultAddImgGuidePos());
    setSelectedId(null);
    setSelLogoIdx(null);
  };
  const applyOverlayGuideRules = () => {
    const hasMovedDeco = !!(deco && decoPos && typeof decoPos === 'object');
    const hasMovedAddImg = !!(exSel === 'image' && addImgGuideMoved());
    if (!hasMovedDeco && !hasMovedAddImg) return false;
    resetOverlayGuide();
    showToast('부가정보 가이드 규칙을 적용했습니다');
    return true;
  };
  const applyGuideRules = () => {
    if (applyOverlayGuideRules()) return;
    const clearManual = (opts) => {opts = opts || {};setScorer({ ...DEFAULT_SCORER_STATE });setEditRects({});if (!opts.keepPatLock) setPatLock(null);setHistory([]);setRedo([]);setSelectedId(null);setFocalHist([]);setFocalRedo([]);resetOverlayGuide();};
    if (layoutType === 'B') {
      if (!bCands.length) { showToast('적용할 배치 후보가 없습니다'); return; }
      setBPinned(bCands[0].id);setBFocal(null);clearManual();showToast('가이드 규칙을 적용했습니다');return;
    }
    if (layoutType === 'C') {
      if (!splitCands.length) { showToast('적용할 배치 후보가 없습니다'); return; }
      setSplitPinned(splitCands[0].id);setSplitFocals({});setSplitPanelOrders({});clearManual();showToast('가이드 규칙을 적용했습니다');return;
    }
    if (layoutType === 'D') {
      if (!fullCands.length) { showToast('적용할 배치 후보가 없습니다'); return; }
      setFullPinned(fullCands[0].id);setFullFocals({});clearManual();showToast('가이드 규칙을 적용했습니다');return;
    }
    if (layoutType === 'E') {
      setEFocal(null);clearManual();showToast('가이드 규칙을 적용했습니다');return;
    }
    if (!productItems.length && fashionCands.length) {
      setPinnedId(fashionCands[0].id);clearManual();showToast('가이드 규칙을 적용했습니다');return;
    }
    const NP = window.NukkiPattern;
    if (!NP || !ranked.length) { showToast('적용할 배치 후보가 없습니다'); return; }
    const current = ranked.find((c) => c.id === (pinnedId || (ranked[0] && ranked[0].id))) || ranked[0];
    const applied = NP.applyGuidePattern(ranked, current, {
      category,
      itemCount: nukkiPatternItems.length,
      productCount: productItems.length,
      modelCount: modelItems.length,
    });
    if (!applied || !applied.pick) { showToast('가이드 규칙에 맞는 후보가 없습니다'); return; }
    clearManual({ keepPatLock: true });
    setPinnedId(applied.pick.id);
    setRankTab(applied.pid || 'all');
    setPatLock(applied.rects ? { id: applied.pick.id, rects: applied.rects, pid: applied.pid } : null);
    setPatternHint('가이드 규칙 적용 · ' + (NP.labelOf(applied.pid) || '자동'));
    showToast('가이드 규칙을 적용했습니다');
  };
  const _loadImgD = (src) => new Promise((res, rej) => {const im = new Image();im.crossOrigin = 'anonymous';im.onload = () => res(im);im.onerror = rej;im.src = src;});
  // 저장 = 팝업 오픈 (행사명 입력 · 폴더 선택). 실제 저장은 SavePopup → onSave → saveBanner.
  const activeReady = layoutType === 'D' ? !!fullPrimary : layoutType === 'C' ? !!splitPrimary : layoutType === 'B' ? !!bPrimary : layoutType === 'E' ? !!eReady : !!editCand;
  const getGuideValidation = () => window.GuideCompliance.validate({
    guideId: 'g550', schema: window.SCHEMA_550, outputCanvas: { w: 550, h: 550 }, enforceContentRules: true,
    category, layout: layoutType, logoCount: logoMeta.length,
    productCount: layoutType === 'B' ? bNukki.length : productItems.length,
    modelCount: layoutType === 'E' ? (eModel ? 1 : 0) : modelItems.length,
    photoCount: layoutType === 'C' ? (splitPrimary && splitPrimary.panels ? splitPrimary.panels.length : 0) : layoutType === 'B' ? (bPhoto ? 1 : 0) : layoutType === 'D' ? (fullPrimary ? 1 : 0) : 0,
    splitBoundaryX: layoutType === 'B' ? B_SPLIT : null,
    insuranceName: eName, addon: exSel, addText, addImageCount: addImgSrc ? 1 : 0, flagCount,
    isDigitalCategory: /디지털|가전/.test(String(category || '')),
    isJewelry: /주얼리|쥬얼리|귀금속/.test(JSON.stringify(allItems || [])),
  });
  const downloadPNG = () => {if (!activeReady) {showToast('먼저 이미지를 올려 배너를 만들어 주세요');return;}setSaveOpen(true);};
  // editCand 를 550×550 PNG blob 으로 렌더
  const renderEditCandBlob = async () => {
    const rects = rectsFor(editCand);
    const cv = document.createElement('canvas');cv.width = 550;cv.height = 550;
    const ctx = cv.getContext('2d');ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 550, 550);
    const ordered = [...rects].sort((a, b) => a.z - b.z);
    for (const r of ordered) {try {const im = await _loadImgD(r.url);drawAdjusted(ctx, im, adjustmentFor(r.src || r.url), r.x, r.y, r.w, r.h);} catch (e) {}}
    // 선택항목(로고·부가정보) 오버레이 합성 — 화면과 동일(우상단). 배치 rects 위에 얹기만 함.
    if (window.BannerOverlay) await window.BannerOverlay.drawStandard(ctx, { logos: logoMeta, deco, decoPos }, { imgTypes: ADD_IMG_TYPES });
    return await new Promise((res) => cv.toBlob(res, 'image/jpeg', 1.0));
  };
  // 선택 폴더에서 중복 없는 파일명 확보 (있으면 -2, -3 …)
  const uniqueInDir = async (dir, name) => {
    const dot = name.lastIndexOf('.');const base = dot >= 0 ? name.slice(0, dot) : name;const ext = dot >= 0 ? name.slice(dot) : '';
    let cand = name, n = 1;
    while (true) {try {await dir.getFileHandle(cand);} catch (e) {return cand;}n += 1;cand = base + '-' + n + ext;}
  };
  const dlCountRef = useSR2({}); // 폴백(다운로드 폴더) 세션 중복 카운트
  // SavePopup 확정 콜백 — 규칙 파일명 생성 → 폴더 저장(+번호) / 폴더 미지원·실패 시 다운로드 폴백
  const saveBanner = async (values, dirHandle) => {
    if (!activeReady) return;
    const built = window.buildBannerFileName(window.SCHEMA_550.fileNameRule, values);
    if (built.missing && built.missing.length) {showToast('필수 미입력: ' + built.missing.join(', '));return;}
    const blob = layoutType === 'D' ? await renderFullBlob(fullPrimary, fullFocalOf(fullPrimary)) : layoutType === 'C' ? await renderSplitBlob(splitPrimary) : layoutType === 'B' ? await renderBBlob(bPrimary, bPhoto, bFocalOf()) : layoutType === 'E' ? await renderEBlob() : await renderEditCandBlob();
    if (!blob) {showToast('이미지 생성에 실패했습니다');return;}
    if (window.__ga4 && window.__ga4.trackDownload) window.__ga4.trackDownload('550', bannerType, (BANNER_TYPES.find((t) => t.id === bannerType) || {}).label || bannerType, ga4Src);
    if (dirHandle) {
      try {
        const finalName = await uniqueInDir(dirHandle, built.name);
        const fh = await dirHandle.getFileHandle(finalName, { create: true });
        const w = await fh.createWritable();await w.write(blob);await w.close();
        try { setAutoMsg('완료 저장됨 · ' + new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul' })); } catch (e) {}
        setSaveOpen(false);showToast('저장했습니다 · ' + finalName);return;
      } catch (e) {showToast('폴더 저장 실패 — 다운로드로 대체합니다');}
    }
    // 폴백: 다운로드 폴더 (폴더 API 미지원/미선택/실패). 같은 이름 재저장은 세션 카운트로 번호.
    const dot = built.name.lastIndexOf('.');const base = dot >= 0 ? built.name.slice(0, dot) : built.name;const ext = dot >= 0 ? built.name.slice(dot) : '';
    const c = dlCountRef.current;const seen = c[built.name] || 0;c[built.name] = seen + 1;
    const finalName = seen === 0 ? built.name : base + '-' + (seen + 1) + ext;
    const a = document.createElement('a');a.href = URL.createObjectURL(blob);a.download = finalName;a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 1000);
    try { setAutoMsg('완료 저장됨 · ' + new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul' })); } catch (e) {}
    setSaveOpen(false);showToast('다운로드했습니다 · ' + finalName);
  };

  // ── 임시저장(작업중) + 편집 중 자동 임시저장 (Supabase draft) ──
  const [autoMsg, setAutoMsg] = useS2('');
  const [savedBtn, setSavedBtn] = useS2(false);
  const draftIdRef = useSR2(null);
  const savedRef = useSR2(false);   // 완료 저장됨 → 이후 자동저장도 같은 항목을 '완료'로 유지(중복 방지)
  const dirtyRef = useSR2(false);
  const saveDraftRef = useSR2(null);
  const sourcesSavedRef = useSR2(false);
  const srcsSigRef = useSR2(''); // 마지막으로 업로드한 이미지 세트 서명 — 바뀌면 전체 교체 업로드
  const outpaintSigRef = useSR2('');
  const outpaintSig = (list) => (list || []).slice(0, 3).map((v) => String(v || '').slice(0, 48)).join('|');
  const failAlertedRef = useSR2(false); // 저장 실패 알림 1회 가드(진단)
  const imgRefMapRef = useSR2({}); // url -> {role,idx} — 이미지 참조 맵(최초 1회 업로드, 재저장·재정렬에도 안정 유지)
  const renderCurrentBlob = () => layoutType === 'D' ? renderFullBlob(fullPrimary, fullFocalOf(fullPrimary)) : layoutType === 'C' ? renderSplitBlob(splitPrimary) : layoutType === 'B' ? renderBBlob(bPrimary, bPhoto, bFocalOf()) : layoutType === 'E' ? renderEBlob() : renderEditCandBlob();
  const _draftType = () => (BANNER_TYPES.find((t) => t.id === bannerType) || {}).label || '';
  // 원본 업로드 이미지 수집(→ '이어서 편집'에 필요). blob으로 변환.
  const collectSources = async () => {
    const out = [];
    for (const s of srcs) { const b = await window.BannerEditorPersist.urlToBlob(s); if (b) out.push({ blob: b, subtype: 'upload' }); }
    return out;
  };
  // 이어서 편집: 저장/복원 로직은 공용 모듈(window.BannerEditorPersist) 사용 — 550/H1 동일 상태라 같은 빌더 공유.
  //   에디터는 자기 상태를 bag 으로 넘기기만(얇은 어댑터).
  const _persistBag = () => ({
    projectCategory: '상품배너 550', category: category, bannerType: bannerType,
    srcs, logoSrcs, logoMeta, addImgSrc, editCand, editRects,
    bFocal, splitFocals, splitPanelOrders, fullFocals, eFocal,
    exSel, addText, addImgKind, addImgPos, decoPos, decoScale, flagCount, flagItems: normalizeFlagItems(flagItems), chipColors, addImgType, addImgTitle, markType,
    scorer, bgMode, imageAdjust, imageAdjustBySource, outpaintVariants, catAuto, pinnedId,
    catCache: catCacheRef.current, headChinData, faceData, detectImageKind,
  });
  const collectProject = (withSources) => window.BannerEditorPersist.buildNukkiProject(_persistBag(), withSources);
  const saveDraft = async (auto, opts) => {
    if (!activeReady) {if (!auto) showToast('저장할 배너가 없습니다');return;}
    if (!window.__myBanners) return;
    const forceSaved = !!(opts && opts.status === 'saved');
    try {
      const blob = await renderCurrentBlob();
      if (!blob) return;
      // 이미지 세트가 바뀌었거나(추가/삭제) 수동 저장이면 → 현재 "전체 이미지"로 교체 업로드. 자동+무변경이면 스냅샷만.
      const _sig = window.BannerEditorPersist.srcsSig(srcs);
      const _outSig = outpaintSig(outpaintVariants);
      const needUpload = (!auto) || (_sig !== srcsSigRef.current) || (_outSig !== outpaintSigRef.current);
      const proj = await collectProject(needUpload);
      const sources = proj.sources || [];
      const r = await window.__myBanners.saveProject({ status: (forceSaved || savedRef.current) ? 'saved' : 'draft', id: draftIdRef.current, category: '상품배너 550', type: _draftType(), event: forceSaved ? '저장 ' + _draftType() : '임시저장 ' + _draftType(), previewBlob: blob, width: 550, height: 550, worker: window.__bannerlyUser || '', snapshot: proj.snapshot, sources: sources });
      if (r && r.ok) {draftIdRef.current = r.id;window.BannerEditorPersist.markSourcesSigIfStored(srcsSigRef, _sig, r, sources.length);if (!r.failed) outpaintSigRef.current = _outSig;
        if (forceSaved) savedRef.current = true;
        var _t = ''; try { _t = new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul' }); } catch (e) {}
        setAutoMsg((auto ? '자동 저장됨' : '저장됨') + ' · ' + _t);
        if (!auto) setSavedBtn(true);
        try { if (sources.length) window.__saveDBG = { 원본: srcs.length, 저장성공: r.stored, 실패: r.failed, 실패이유: r.errs || [] }; } catch (e) {}
        try { if (r.failed && !failAlertedRef.current) { failAlertedRef.current = true; alert('⚠️ 이미지 저장 실패 ' + r.failed + '장\n' + (r.errs || []).join('\n')); } } catch (e) {}}
      else if (!auto) showToast('저장 실패: ' + ((r && r.error) || '오류'));
    } catch (e) {if (!auto) showToast('저장 실패');}
  };
  saveDraftRef.current = saveDraft;
  useSE2(() => {
    const mark = () => {dirtyRef.current = true;setSavedBtn((v) => v ? false : v);};
    document.addEventListener('pointerup', mark, true);
    document.addEventListener('keyup', mark, true);
    document.addEventListener('change', mark, true);
    const iv = setInterval(() => {if (dirtyRef.current && saveDraftRef.current) {dirtyRef.current = false;saveDraftRef.current(true);}}, 6000);
    return () => {clearInterval(iv);document.removeEventListener('pointerup', mark, true);document.removeEventListener('keyup', mark, true);document.removeEventListener('change', mark, true);};
  }, []);

  // ── 이어서 편집(레시피): props.restore 의 재료·설정을 세팅 → 엔진이 자기 방식대로 재렌더 ──
  //   엔진 파일은 안 건드림(별도 기능이 엔진을 "불러다" 씀). 모델·겹침·중복 이미지 모두 엔진이 재현.
  const restoreDoneRef = useSR2(false);
  const pendingRestoreRef = useSR2(null); // {pinnedId, editRects(기하)} — 후보 생성 후 적용
  useSE2(() => {
    const rst = props && props.restore;
    if (!rst || restoreDoneRef.current) return;
    const BS = window.BannerSnapshot; const snap = rst.snapshot;
    if (!BS || !BS.isValid(snap) || snap.category !== '상품배너 550') return; // 내 카테고리 스냅샷만
    restoreDoneRef.current = true;
    const byRole = rst.images || {};
    const ed = snap.editor || {};
    const ov = snap.overlays || {};
    const resolveRef = (ref) => ref ? ((byRole[ref.role] || [])[ref.idx] || null) : null;
    // 설정(유형·겹침/밀도·배경모드·카테고리)
    if (ed.bannerType) { setBannerType(ed.bannerType); setLayoutType(ed.bannerType); manualTypeRef.current = true; } else if (snap.type) { setBannerType(snap.type); setLayoutType(snap.type); manualTypeRef.current = true; }
    if (ed.scorer) setScorer({ ...ed.scorer });
    if (ed.bgMode) setBgMode(ed.bgMode);
    if (ed.imageAdjust) setImageAdjust(ed.imageAdjust);
    const restoredCategory = restoredCommerceCategory(ed.category);
    if (ed.category !== undefined) setCategory(restoredCategory);
    if (ed.catAuto !== undefined) setCatAuto(restoredCategory ? ed.catAuto : true);
    if (ed.focals) {
      if (ed.focals.b != null) setBFocal(ed.focals.b);
      if (ed.focals.s && typeof ed.focals.s === 'object') setSplitFocals(ed.focals.s);
      if (ed.focals.f && typeof ed.focals.f === 'object') setFullFocals(ed.focals.f);
      if (ed.focals.e != null) setEFocal(ed.focals.e);
    }
    if (ed.splitPanelOrders && typeof ed.splitPanelOrders === 'object') setSplitPanelOrders(ed.splitPanelOrders);
    // 오버레이(로고/부가/칩) — logoMeta/deco 자동 재계산
    if (ov.exSel !== undefined) setExSel(ov.exSel);
    if (ov.addText !== undefined) setAddText(ov.addText || '');
    if (ov.addImgKind !== undefined) setAddImgKind(ov.addImgKind || null);
    if (ov.addImgPos) setAddImgPos(normalizeRestoredAddImgPos(ov.addImgPos));
    if (ov.decoPos !== undefined) setDecoPos(ov.decoPos || null);
    if (ov.decoScale !== undefined) setDecoScale(clampDecoScale(ov.decoScale));
    if (ov.flagCount) setFlagCount(ov.flagCount);
    if (Array.isArray(ov.flagItems)) {
      setFlagItems(normalizeFlagItems(ov.flagItems.map((item) => ({ ...item, src: resolveRef(item && item.imgRef) || item.src || null }))));
    }
    if (Array.isArray(ov.chipColors)) setChipColors(ov.chipColors);
    if (ov.addImgType) setAddImgType(ov.addImgType);
    if (ov.addImgTitle !== undefined) setAddImgTitle(String(ov.addImgTitle || '').slice(0, 12));
    if (ov.markType !== undefined) setMarkType(ov.markType);
    const logoUrls = (ov.logoRefs || []).map(resolveRef).filter(Boolean);
    // Legacy single-scale restore (both axes), overridden by per-axis arrays below
    if (Array.isArray(ov.logoScales)) pendingLogoScalesRef.current = ov.logoScales;
    if (Array.isArray(ov.logoScaleWs)) pendingLogoScaleWsRef.current = ov.logoScaleWs;
    if (Array.isArray(ov.logoScaleHs)) pendingLogoScaleHsRef.current = ov.logoScaleHs;
    if (logoUrls.length) setLogoSrcs(logoUrls);
    const addImgUrl = resolveRef(ov.addImgRef);
    if (addImgUrl) setAddImgSrc(addImgUrl);
    if (Array.isArray(ov.outpaintRefs)) setOutpaintVariants(ov.outpaintRefs.map(resolveRef).filter(Boolean).slice(0, 3));
    // 올린 이미지 전부(중복·모델 포함, 순서대로) → 엔진이 재분류/재배치
    const uploads = byRole.upload || [];
    if (Array.isArray(ed.imageAdjustBySource)) {
      const nextAdjust = {};
      uploads.forEach((url, i) => { if (ed.imageAdjustBySource[i]) nextAdjust[url] = ed.imageAdjustBySource[i]; });
      setImageAdjustBySource(nextAdjust);
    }
    // 이미지별 태그(분류/누끼/얼굴정렬)를 캐시에 주입 → 재판별 없이 확실히 복원(인물합성 안정). 공용 로직.
    window.BannerEditorPersist.seedNukkiCaches(uploads, ed.imageMeta, { catCache: catCacheRef.current, faceCache: faceCacheRef.current, setFaceData: setFaceData, setHeadChinData: setHeadChinData });
    if (uploads.length) setSrcs(uploads);
    showToast('복원 · 이미지 ' + uploads.length + '장 (저장 배치: ' + (ed.pinnedId || '-') + ')');
    try { window.__restoreDBG = { uploads: uploads.length, overlay: (byRole.overlay || []).length, pinnedId: ed.pinnedId, scorer: ed.scorer, drag: !!(ed.editRects && ed.editRects.length) }; } catch (e) {}
    // 선택 후보 + 드래그(기하)는 후보 생성된 뒤 적용(pending)
    pendingRestoreRef.current = { pinnedId: ed.pinnedId || null, editRects: (ed.editRects && ed.editRects.length) ? ed.editRects : null };
    try { console.log('[복원DBG] type=', ed.bannerType || snap.type, 'uploads=', uploads.length, 'scorer=', ed.scorer, 'pinnedId=', ed.pinnedId, 'drag=', !!(ed.editRects && ed.editRects.length)); } catch (e) {}
    // 복원된 이미지 세트 서명 기록 → 이미지 안 바꾸면 재업로드 안 함(바꾸면 전체 교체 업로드)
    srcsSigRef.current = window.BannerEditorPersist.srcsSig(uploads);
    outpaintSigRef.current = outpaintSig(ov.outpaintRefs && ov.outpaintRefs.map(resolveRef).filter(Boolean));
    if (rst.id) { draftIdRef.current = rst.id; }
    if (props.onRestoreConsumed) { try { props.onRestoreConsumed(); } catch (e) {} }
  }, []);

  // 후보가 생성되면 저장된 '선택 후보' + 드래그(기하)를 적용 (엔진이 만든 rects에 좌표만 덮어씀)
  useSE2(() => {
    const pend = pendingRestoreRef.current;
    if (!pend) return;
    if (!pend.pinnedId) { pendingRestoreRef.current = null; return; }
    const pid = pend.pinnedId;
    const cand = ranked.find((c) => c.id === pid) || fashionCands.find((c) => c.id === pid);
    if (!cand) return; // 아직 후보 생성 전 → 다음 렌더에서 재시도(엔진 바뀌어 id 없으면 기본 후보 유지=graceful)
    setPinnedId(pid);
    if (pend.editRects && cand.rects) {
      const base = cand.rects;
      const merged = base.map((r, i) => { const g = pend.editRects[i]; return g ? Object.assign({}, r, { x: g.x, y: g.y, w: g.w, h: g.h, z: g.z }) : r; });
      setEditRects((p) => Object.assign({}, p, { [pid]: merged }));
    }
    pendingRestoreRef.current = null;
  }, [ranked, fashionCands]);

  useSE2(() => {
    const pending = pendingSourceEditRef.current;
    if (!pending) return;
    const edits = pending.replacements || [{ oldSrc: null, oldKeys: pending.oldKeys || [], nextSrc: pending.nextSrc }];
    const prepared = edits.map((edit) => ({ edit, item: allItems.find((candidate) => candidate && (candidate.src === edit.nextSrc || candidate.url === edit.nextSrc)) }));
    if (prepared.some((entry) => !entry.item)) return;
    let changed = false;
    const rects = pending.rects.map((rect) => {
      const entry = prepared.find(({ edit }) => rectMatchesSources(rect, new Set(edit.oldKeys || [edit.oldSrc])));
      if (!entry) return rect;
      const replacement = entry.item;
      changed = true;
      return { ...rect, id: replacement.id, src: replacement.src, url: replacement.url, aspect: replacement.aspect, alpha: replacement.alpha };
    });
    if (changed) preserveCandidate(pending.cand, rects);
    pendingSourceEditRef.current = null;
  }, [allItems]);

  // 업로드/배경모드/유형 변경 시 편집 이력 초기화 (로고는 오버레이라 상품 배치를 초기화하지 않음)
  useSE2(() => {
    if (pendingRestoreRef.current) return;
    const prev = resetStateRef.current;
    resetStateRef.current = { srcs, bgMode, bannerType };
    if (preserveSrcChangeRef.current) { setSelectedId(null); return; }
    const onlyOptionsChanged = !!(prev && prev.srcs === srcs && (prev.bgMode !== bgMode || prev.bannerType !== bannerType));
    setEditRects({});setPatLock(null);setHistory([]);setRedo([]);setSelectedId(null);setPinnedId(null);setRestoredCand(null);setSplitPanelOrders({});
    if (onlyOptionsChanged) setSelectedId(null);
  }, [srcs, bgMode, bannerType]);
  // 새 이미지(srcs) 업로드 시 고급설정 값을 기본값으로 리셋. 복원 직후엔 저장값을 덮지 않음.
  useSE2(() => {if (pendingRestoreRef.current) return;if (preserveSrcChangeRef.current) {preserveSrcChangeRef.current = false;return;}setScorer({ ...DEFAULT_SCORER_STATE });setBgMode(DEFAULT_BG_MODE);}, [srcs]);
  // ⌘Z 되돌리기 / ⇧⌘Z · ⌘Y 다시 실행
  useSE2(() => {const id = 'noto-kr-badge';if (document.getElementById(id)) return;const l = document.createElement('link');l.id = id;l.rel = 'stylesheet';l.href = 'https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap';document.head.appendChild(l);}, []);
  useSE2(() => {const onKey = (e) => {if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {e.preventDefault();if (e.shiftKey) redoRef.current && redoRef.current();else undoRef.current && undoRef.current();}if ((e.metaKey || e.ctrlKey) && (e.key === 'y' || e.key === 'Y')) {e.preventDefault();redoRef.current && redoRef.current();}};window.addEventListener('keydown', onKey);return () => window.removeEventListener('keydown', onKey);}, []);
  // [INP-FIX] 언마운트 시 대기 중인 layoutType rAF 취소
  useSE2(() => () => { if (layoutTypeRafRef.current !== null) { cancelAnimationFrame(layoutTypeRafRef.current); layoutTypeRafRef.current = null; } }, []);

  const typeReady = BANNER_TYPES.find((t) => t.id === bannerType).ready;
  const required = srcs.length > 0;
  const labelOf = (i) => {const it = allItems[i];if (!it) return null;return it.label;};

  return (
    <div className="ap550">
      {/* ── 에디터 헤더: 좌 배너이름+정보 / 우 저장하기 ── */}
      <header className="ap-editor-header">
        <div className="ap-eh-left">
          <span className="ap-eh-title">상품배너 550</span>
          {(ranked.length > 0 || productItems.length > 0 || modelItem) && <span className="ap-eh-info">{ranked.length > 0 && <b>{ranked.length}개 후보</b>}{ranked.length > 0 && (productItems.length > 0 || modelItem) && ' · '}{productItems.length > 0 && `상품 ${productItems.length}`}{productItems.length > 0 && modelItem && ' + '}{modelItem && '모델 1'}</span>}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: 'auto' }}>
          {autoMsg && <span style={{ fontSize: 12, color: '#12A150', fontWeight: 600, whiteSpace: 'nowrap' }}>✓ {autoMsg}</span>}
          <button className="ap-eh-save ghost" onClick={() => saveDraft(false, { status: 'saved' })} disabled={!activeReady} style={!activeReady ? {opacity:0.45,cursor:'not-allowed'} : {}}>{savedBtn ? '저장됨' : '저장하기'}</button>
          <button className="ap-eh-save" onClick={downloadPNG} disabled={!activeReady} style={!activeReady ? {opacity:0.45,cursor:'not-allowed'} : {}}>다운로드</button>
        </div>
      </header>
      <div className="ap-body" style={{ gridTemplateColumns: '340px minmax(0, 1fr) 320px' }}>
        {/* ── 좌측: 필수 ── */}
        <aside className="ap-controls">
          <input ref={upRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={(e) => {addImages(e.target.files);e.target.value = '';}} />
          <input ref={logoRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={(e) => {addLogos(e.target.files);e.target.value = '';}} />
          <input ref={addImgRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => {setAddImg(e.target.files);e.target.value = '';}} />
          <input ref={replaceRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => {replaceSrcAt(e.target.files);e.target.value = '';}} />

          <div style={{ fontSize: '11px', fontWeight: 800, color: '#9a9aa6', letterSpacing: '0.04em', marginBottom: '10px' }}>필수 항목</div>

          <section className="ap-sec">
            <div className="ap-sec-head"><span className="ap-step">1</span>카테고리<span className="ap-opt">자동 감지 · 수정 가능</span></div>
            <div ref={catBoxRef} style={{ position: 'relative' }}>
              <button onClick={() => setCatOpen((v) => !v)} style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', padding: '10px 12px', borderRadius: '10px', border: category ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: category ? '#eef2ff' : '#fff', cursor: 'pointer', font: 'inherit' }}>
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: '7px' }}>
                  {catBusy && !category ? <><TSpinner size={13} /><span style={{ fontSize: '12.5px', color: '#8a8a96' }}>감지 중…</span></> :
                  category ? <><span style={{ fontWeight: 800, fontSize: '13px', color: '#1c1c22' }}>{category}</span><span style={{ fontSize: '10px', fontWeight: 700, padding: '1px 6px', borderRadius: '5px', background: catAuto ? '#eef2ff' : '#f0f0f3', color: catAuto ? '#4E4CDB' : '#8a8a96' }}>{catAuto ? '자동' : '수동'}</span></> :
                  <span style={{ fontSize: '12.5px', color: '#8a8a96' }}>이미지 업로드 시 자동 감지</span>}
                </span>
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8a8a96" strokeWidth="2" strokeLinecap="round"><path d="m6 9 6 6 6-6" /></svg>
              </button>
              {catOpen &&
              <div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 20, background: '#fff', border: '1px solid #e7e7ee', borderRadius: '10px', boxShadow: '0 8px 24px rgba(0,0,0,0.1)', padding: '4px', maxHeight: '260px', overflowY: 'auto' }}>
                  {COMMERCE_CATS.map((c) => <button key={c} className="ap-cat-opt" onClick={() => {setCategory(c);setCatAuto(false);setCatOpen(false);}} style={{ width: '100%', textAlign: 'left', padding: '8px 10px', borderRadius: '7px', border: 'none', cursor: 'pointer', font: 'inherit', fontSize: '12.5px', fontWeight: c === category ? 800 : 500, color: c === category ? '#4E4CDB' : '#1c1c22', background: c === category ? '#eef2ff' : 'transparent' }}>{c}</button>)}
                </div>}
            </div>
          </section>

          <section className="ap-sec">
            {/* step 2 헤더 + 업로드|불러오기 세그먼트 컨트롤 */}
            <div className="ap-sec-head" style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
              <span className="ap-step">2</span>이미지<span className="ap-req">필수</span>
              <span className="ap-count" style={{ marginRight: 'auto' }}>{srcs.length}/{rule.prodMax + rule.modelMax}</span>
              <span style={{ display: 'inline-flex', border: '1.5px solid #e0e0ea', borderRadius: '8px', overflow: 'hidden', flexShrink: 0 }}>
                {[{ id: 'upload', label: '업로드' }, { id: 'library', label: '불러오기' }].map((tab) => (
                  <button key={tab.id} type="button" onClick={() => { setImgTab(tab.id); if (tab.id === 'library') ensureLibDb(); }}
                    style={{ padding: '3px 10px', fontSize: '11px', fontWeight: imgTab === tab.id ? 700 : 500, font: 'inherit', border: 'none', cursor: 'pointer', background: imgTab === tab.id ? '#4E4CDB' : 'transparent', color: imgTab === tab.id ? '#fff' : '#6b6b78', transition: 'background .12s,color .12s' }}>
                    {tab.label}
                  </button>
                ))}
              </span>
            </div>

            {/* ── 업로드 탭 ── */}
            {imgTab === 'upload' && <>
              {srcs.length === 0 ?
              <div className="ap-drop" onClick={() => upRef.current.click()} onDragOver={(e) => {e.preventDefault();e.currentTarget.classList.add('over');}} onDragLeave={(e) => e.currentTarget.classList.remove('over')} onDrop={(e) => {e.preventDefault();e.currentTarget.classList.remove('over');addImages(e.dataTransfer.files);}}>
                  <div className="ap-drop-ic"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3" /><circle cx="8.5" cy="8.5" r="1.6" /><path d="m21 15-5-5L5 21" /></svg></div>
                  <div className="ap-drop-t">상품·모델 이미지를 올려주세요</div>
                  <div className="ap-drop-h">업로드하면 자동으로 분류돼요</div>
                </div> :
              <>
                  <div className="ap-thumbs">
                    {srcs.map((src, i) => {
                    const lab = labelOf(i);const isM = isModelLabel(lab);
                    return (
                      <div className="ap-thumb ap-upload-thumb" key={'u-' + i + '-' + src.slice(-12)}
                          draggable
                          onDragStart={(e) => onThumbDragStart(e, i)}
                          onDragOver={(e) => {e.preventDefault();e.dataTransfer.dropEffect = 'move';}}
                          onDrop={(e) => onThumbDrop(e, i)}
                          onClick={() => setNukkiSel((p) => p.includes(src) ? p.filter((s) => s !== src) : [...p, src])}
                          style={{ cursor: 'pointer', outline: nukkiSel.includes(src) ? '2px solid #4E4CDB' : 'none', outlineOffset: '-2px' }}>
                        <div className="ap-thumb-media">
                          <img src={src} alt="" draggable={false} />
                          {nukkiSel.includes(src) && <span style={{ position: 'absolute', inset: 0, background: 'rgba(78,76,219,0.15)', borderRadius: 'inherit', pointerEvents: 'none' }} />}
                          {nukkiSel.includes(src) && <span style={{ position: 'absolute', right: '4px', bottom: '4px', width: '14px', height: '14px', borderRadius: '50%', background: '#4E4CDB', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none', zIndex: 2 }}><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg></span>}
                          {lab && <span style={{ position: 'absolute', left: '4px', bottom: '4px', padding: '1px 6px', fontSize: '10px', fontWeight: 700, lineHeight: 1.5, color: '#fff', background: isM ? 'rgba(217,131,36,0.95)' : 'rgba(78,76,219,0.92)', borderRadius: '5px', pointerEvents: 'none' }}>{isM ? '모델' : lab}</span>}
                          {catBusy && !lab && <span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(248,248,251,0.66)', borderRadius: 'inherit', pointerEvents: 'none' }}><TSpinner size={16} /></span>}
                          {bannerType === 'B' && (() => {const isPhoto = bPhoto && bPhoto.src === src;return <button type="button" title={isPhoto ? '화보(우 패널) · 눌러서 해제' : '눌러서 화보(우 패널)로 지정'} onClick={(e) => {e.preventDefault();e.stopPropagation();setBPhotoSrc(isPhoto ? null : src);}} style={{ position: 'absolute', left: '4px', bottom: '4px', padding: '1px 6px', fontSize: '10px', fontWeight: 700, lineHeight: 1.5, color: '#fff', border: 'none', borderRadius: '5px', cursor: 'pointer', background: isPhoto ? 'rgba(78,76,219,0.95)' : 'rgba(28,28,34,0.6)' }}>{isPhoto ? '화보' : '누끼'}</button>;})()}
                          {bannerType === 'E' && (() => {const isModel = eModelSrc === src;return <button type="button" title={isModel ? '인물누끼(우측 상체·최전면) · 눌러서 해제' : '눌러서 인물누끼(우측 상체)로 지정'} onClick={(e) => {e.preventDefault();e.stopPropagation();eModelTouchedRef.current = true;setEModelSrc(isModel ? null : src);}} style={{ position: 'absolute', left: '4px', bottom: '4px', padding: '1px 6px', fontSize: '10px', fontWeight: 700, lineHeight: 1.5, color: '#fff', border: 'none', borderRadius: '5px', cursor: 'pointer', background: isModel ? 'rgba(217,131,36,0.95)' : 'rgba(28,28,34,0.6)' }}>{isModel ? '인물누끼' : '일러스트'}</button>;})()}
                          {bannerType === 'C' && <span style={{ position: 'absolute', left: 4, top: 4, minWidth: 16, height: 16, padding: '0 5px', borderRadius: 999, background: 'rgba(78,76,219,.94)', color: '#fff', fontSize: 10, fontWeight: 800, lineHeight: '16px', textAlign: 'center', pointerEvents: 'none', zIndex: 3 }}>{i + 1}</span>}
                          {false && isM && <button type="button" title="머리끝·턱끝 기준선 지정" onClick={(e) => {e.preventDefault();e.stopPropagation();setEditFaceSrc(src);}} style={{ position: 'absolute', left: '4px', top: '4px', width: '18px', height: '18px', borderRadius: '5px', border: 'none', background: headChinData[src] ? '#4E4CDB' : 'rgba(28,28,34,.6)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"><path d="M3 8h18M3 16h18" /></svg></button>}
                        </div>
                        <div className="ap-thumb-actions" aria-label="이미지 액션">
                          <button type="button" className="ap-thumb-action" title="누끼 재편집" onClick={(e) => {e.preventDefault();e.stopPropagation();editSingleSrc(src);}}><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12" /></svg></button>
                          <button type="button" className="ap-thumb-action" title="이미지 교체" onClick={(e) => {e.preventDefault();e.stopPropagation();requestReplaceSrc(i);}}><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 0 1-15.6 6.1L3 16" /><path d="M3 21v-5h5" /><path d="M3 12A9 9 0 0 1 18.6 5.9L21 8" /><path d="M21 3v5h-5" /></svg></button>
                          <button type="button" className="ap-thumb-action" title="앞으로 이동" disabled={i === 0} onClick={(e) => {e.preventDefault();e.stopPropagation();moveSrc(i, i - 1);}}><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6" /></svg></button>
                          <button type="button" className="ap-thumb-action" title="뒤로 이동" disabled={i === srcs.length - 1} onClick={(e) => {e.preventDefault();e.stopPropagation();moveSrc(i, i + 1);}}><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="m9 18 6-6-6-6" /></svg></button>
                          <button type="button" className="ap-thumb-action danger" title="이미지 삭제" onClick={(e) => {e.preventDefault();e.stopPropagation();removeSrc(i);}}><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg></button>
                        </div>
                        </div>);

                  })}
                    <button className="ap-thumb-add" onClick={() => upRef.current.click()} {...dz(addImages)}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg></button>
                    <button onClick={clearAll} title="전체 비우기" className="ap-clear-tile" style={{ aspectRatio: '1', border: '1.5px solid #d8d8e0', borderRadius: '9px', color: '#8a8a96', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '4px', font: 'inherit', fontSize: '10.5px', fontWeight: 700, background: "rgb(244, 244, 244)" }}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>전체 삭제</button>
                  </div>
                  <div className="ap-drophint">{bannerType === 'E' ? <>업로드는 <b style={{ color: '#4E4CDB' }}>일러스트</b>, 썸네일에서 한 장을 <b style={{ color: '#D98324' }}>모델</b>(우측)로 지정할 수 있어요</> : <>인물이면 <b style={{ color: '#D98324' }}>모델</b>(우측 밴드), 없으면 <b style={{ color: '#4E4CDB' }}>상품</b>(좌측 클러스터)으로 자동 분류</>}</div>
                </>}
              <button type="button" onClick={openNukkiPopup} style={{ width: '100%', marginTop: '10px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '7px', border: '1.5px solid #4E4CDB', borderRadius: '10px', background: '#fff', color: '#4E4CDB', font: 'inherit', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><line x1="20" y1="4" x2="8.12" y2="15.88" /><line x1="14.47" y1="14.48" x2="20" y2="20" /><line x1="8.12" y1="8.12" x2="12" y2="12" /></svg>
                누끼 이미지 만들기
              </button>
              {window.AssetLibraryField && <div style={{ marginTop: '8px' }}><AssetLibraryField kind="model" max={rule.modelMax || 3} onApply={applyLibraryModels} /></div>}
            </>}

            {/* ── 불러오기 탭 ── */}
            {imgTab === 'library' && <div style={{ marginTop: '4px' }}>
              {/* MID 검색 */}
              <div style={{ display: 'flex', gap: '6px', marginBottom: '10px' }}>
                <input type="text" value={libMidInput} onChange={(e) => setLibMidInput(e.target.value)}
                  onKeyDown={(e) => { if (e.key === 'Enter') searchByMids(); }}
                  placeholder="MID로 검색 (숫자, 여러 개는 쉼표 또는 공백)"
                  style={{ flex: 1, padding: '8px 10px', fontSize: '12px', border: '1px solid #e0e0ea', borderRadius: '8px', font: 'inherit', outline: 'none', minWidth: 0 }} />
                <button type="button" onClick={searchByMids} disabled={!libDb || !libMidInput.trim()}
                  style={{ padding: '8px 12px', fontSize: '12px', fontWeight: 700, border: 'none', borderRadius: '8px', background: '#4E4CDB', color: '#fff', cursor: libDb && libMidInput.trim() ? 'pointer' : 'default', opacity: libDb && libMidInput.trim() ? 1 : 0.5, font: 'inherit', flexShrink: 0 }}>
                  검색
                </button>
              </div>
              {/* MID 검색 결과 */}
              {libMidResults && <>
                {libMidResults.found.length > 0 && <>
                  <div style={{ fontSize: '11px', fontWeight: 700, color: '#4E4CDB', marginBottom: '6px' }}>검색 결과 {libMidResults.found.length}건</div>
                  <div className="ap-thumbs" style={{ marginBottom: '8px' }}>
                    {libMidResults.found.map((p) => {
                      const url = catalogCanvasUrl(p);
                      if (!url) return null;
                      const sel = srcs.includes(url) || !!(catalogBlobMapRef.current[url] && srcs.includes(catalogBlobMapRef.current[url]));
                      return (
                        <div key={p.mid} className="ap-thumb" style={{ cursor: 'pointer', outline: sel ? '2px solid #4E4CDB' : 'none', outlineOffset: '-2px' }}
                          onClick={() => toggleCatalogUrl(url, p)} title={p.name || p.mid}>
                          <img src={toThumbUrl(p)} alt={p.name || ''} draggable={false} style={{ objectFit: 'cover' }} />
                          {sel && <span style={{ position: 'absolute', inset: 0, background: 'rgba(78,76,219,0.18)', borderRadius: 'inherit', pointerEvents: 'none' }} />}
                          {sel && <span style={{ position: 'absolute', right: '4px', top: '4px', width: '14px', height: '14px', borderRadius: '50%', background: '#4E4CDB', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg></span>}
                          <div style={{ position: 'absolute', left: '4px', bottom: '4px', right: '4px', fontSize: '9px', fontWeight: 600, color: '#fff', textShadow: '0 1px 3px rgba(0,0,0,.7)', lineHeight: 1.2, pointerEvents: 'none', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</div>
                        </div>);
                    })}
                  </div>
                </>}
                {libMidResults.missing.length > 0 && <div style={{ fontSize: '11.5px', color: '#9a9aa6', padding: '6px 2px', marginBottom: '6px' }}>등록 이미지에 없는 MID: {libMidResults.missing.join(', ')}</div>}
                {libMidResults.found.length === 0 && libMidResults.missing.length > 0 && <div style={{ fontSize: '12px', color: '#9a9aa6', textAlign: 'center', padding: '12px 0' }}>일치하는 MID가 없어요</div>}
              </>}
              {srcs.length > 0 && <div data-library-selected-panel style={{ position: 'sticky', top: 0, zIndex: 4, margin: '8px 0 10px', padding: '8px', border: '1px solid #dfe3f5', borderRadius: '10px', background: '#fff', boxShadow: '0 4px 10px rgba(19,22,42,.06)' }}>
                <div style={{ fontSize: '11.5px', color: '#43434e', marginBottom: '6px', fontWeight: 700 }}>선택한 이미지 모아보기 {srcs.length}장 — <button type="button" onClick={clearAll} style={{ border: 'none', background: 'none', color: '#4E4CDB', cursor: 'pointer', font: 'inherit', fontSize: '11.5px', fontWeight: 700, padding: 0 }}>전체 해제</button></div>
                <div className="ap-thumbs" data-library-selected-thumbs style={{ marginBottom: 0 }}>
                  {srcs.map((src, i) => (
                    <div className="ap-thumb ap-upload-thumb" key={'lib-sel-' + i + '-' + src.slice(-12)} style={{ cursor: 'default' }}>
                      <div className="ap-thumb-media"><img src={src} alt="" draggable={false} /></div>
                      <div className="ap-thumb-actions single" aria-label="불러오기 선택 이미지 액션">
                        <button type="button" className="ap-thumb-action danger" title="이미지 삭제" onClick={(e) => {e.preventDefault();e.stopPropagation();removeSrc(i);}}><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg></button>
                      </div>
                    </div>
                  ))}
                </div>
              </div>}
              {/* 카테고리별 이미지 */}
              <div style={{ fontSize: '11px', fontWeight: 700, color: '#43434e', marginBottom: '6px' }}>
                {category ? `${category} 등록 이미지` : '카테고리 등록 이미지'}
              </div>
              {libDbBusy && <div style={{ textAlign: 'center', padding: '20px 0', color: '#9a9aa6', fontSize: '12px' }}><TSpinner size={14} /> 불러오는 중…</div>}
              {!libDbBusy && libDb && libCatProducts.length === 0 && !(libMidResults && libMidResults.found.length > 0) && <div style={{ fontSize: '12px', color: '#9a9aa6', textAlign: 'center', padding: '16px 0' }}>이 카테고리에 등록된 이미지가 없어요<br /><span style={{ fontSize: '11px' }}>MID로 직접 검색해 보세요</span></div>}
              {!libDbBusy && libCatProducts.length > 0 && <div className="ap-thumbs">
                {libCatProducts.map((p) => {
                  const url = catalogCanvasUrl(p);
                  if (!url) return null;
                  const sel = srcs.includes(url) || !!(catalogBlobMapRef.current[url] && srcs.includes(catalogBlobMapRef.current[url]));
                  return (
                    <div key={p.mid || p.id} className="ap-thumb" style={{ cursor: 'pointer', outline: sel ? '2px solid #4E4CDB' : 'none', outlineOffset: '-2px' }}
                      onClick={() => toggleCatalogUrl(url, p)} title={p.name || ''}>
                      <img src={toThumbUrl(p)} alt={p.name || ''} draggable={false} style={{ objectFit: 'cover' }} />
                      {sel && <span style={{ position: 'absolute', inset: 0, background: 'rgba(78,76,219,0.18)', borderRadius: 'inherit', pointerEvents: 'none' }} />}
                      {sel && <span style={{ position: 'absolute', right: '4px', top: '4px', width: '14px', height: '14px', borderRadius: '50%', background: '#4E4CDB', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg></span>}
                      <div style={{ position: 'absolute', left: '4px', bottom: '4px', right: '4px', fontSize: '9px', fontWeight: 600, color: '#fff', textShadow: '0 1px 3px rgba(0,0,0,.7)', lineHeight: 1.2, pointerEvents: 'none', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</div>
                    </div>);
                })}
              </div>}
              <button type="button" onClick={openNukkiPopup} style={{ width: '100%', marginTop: '10px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '7px', border: '1.5px solid #4E4CDB', borderRadius: '10px', background: '#fff', color: '#4E4CDB', font: 'inherit', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><line x1="20" y1="4" x2="8.12" y2="15.88" /><line x1="14.47" y1="14.48" x2="20" y2="20" /><line x1="8.12" y1="8.12" x2="12" y2="12" /></svg>
                누끼 이미지 만들기
              </button>
              {window.AssetLibraryField && <div style={{ marginTop: '8px' }}><AssetLibraryField kind="model" max={rule.modelMax || 3} onApply={applyLibraryModels} /></div>}
            </div>}
          </section>


          {/* ── step 3: 배치 패턴 (A 누끼형 + 이미지가 있을 때만) ── */}
          {bannerType === 'A' && srcs.length > 0 && (() => {
            // 패턴 버튼은 전체 후보 기준으로 노출. 적용 시에만 누끼 영역(인물 유지).
            const NP = window.NukkiPattern;
            if (!NP) return null;
            const current = ranked.find((c) => c.id === (pinnedId || (ranked[0] && ranked[0].id))) || ranked[0];
            const boxCands   = NP.listByPattern(ranked, 'box');
            const trapCands  = NP.listByPattern(ranked, 'trap');
            const blockCands = NP.listByPattern(ranked, 'bundle');

            const PATTERNS = [
              { id: 'box',    label: '박스형',   cands: boxCands,   icon: <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3" /></svg> },
              { id: 'trap',   label: '사다리꼴',  cands: trapCands,  icon: <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 18h12L16 6H8L6 18Z" /></svg> },
              { id: 'bundle', label: '묶음',     cands: blockCands, icon: <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="8" height="8" rx="2" /><rect x="13" y="3" width="8" height="8" rx="2" /><rect x="3" y="13" width="8" height="8" rx="2" /><rect x="13" y="13" width="8" height="8" rx="2" /></svg> },
            ];

            const activePattern = (patLock && current && patLock.id === current.id && patLock.pid) || NP.patternIdOf(current);

            const selectPattern = (pid, cands) => {
              if (!cands.length) { setPatternHint('이 이미지로는 ' + PATTERNS.find(p => p.id === pid).label + ' 후보가 없어요'); return; }
              setPatternHint('');
              const applied = NP.applyPattern(ranked, current, pid);
              if (!applied) return;
              setPinnedId(applied.pick.id);
              setRankTab(pid);
              setPatLock(applied.rects ? { id: applied.pick.id, rects: applied.rects, pid: pid } : null);
            };

            if (!ranked.length) return null;

            return (
              <section className="ap-sec">
                <div className="ap-sec-head"><span className="ap-step">3</span>배치 패턴</div>
                <div className="ap-pattern-btns">
                  {PATTERNS.map((p) => {
                    const on = activePattern === p.id;
                    const hasAny = p.cands.length > 0;
                    return (
                      <button key={p.id} className={'ap-pattern-btn' + (on ? ' on' : '')}
                        disabled={!hasAny}
                        title={!hasAny ? '이 이미지로는 ' + p.label + ' 후보가 없어요' : ''}
                        onClick={() => selectPattern(p.id, p.cands)}>
                        {React.cloneElement(p.icon, { stroke: on ? '#4E4CDB' : '#6b6b78' })}
                        {p.label}
                        {!hasAny && <span style={{ marginLeft: 'auto', fontSize: '10.5px', fontWeight: 600, color: '#9a9aa6' }}>없음</span>}
                      </button>
                    );
                  })}
                  {patternHint && <p className="ap-pattern-hint">{patternHint}</p>}
                </div>
              </section>
            );
          })()}

          <section className="ap-sec">
            <button onClick={() => setAdvOpen((v) => !v)} style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit', padding: 0 }}>
              <span className="ap-sec-head" style={{ margin: 0 }}><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#8a8a96" strokeWidth="2.4" strokeLinecap="round" style={{ transform: advOpen ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }}><path d="m9 6 6 6-6 6" /></svg>고급 설정<span className="ap-opt">배경처리 · 스코어러</span></span>
            </button>
            {advOpen &&
            <div style={{ marginTop: '10px' }}>
                {editCand && editRects[editCand.id] && <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 10px', marginBottom: '10px', borderRadius: '8px', background: '#fff7ed', border: '1px solid #fed7aa', fontSize: '11.5px', color: '#9a3412' }}><span style={{ flex: 1 }}>손편집 중 — 고급설정 잠김</span><button onClick={resetPrimary} style={{ padding: '5px 9px', borderRadius: '7px', border: '1px solid #fdba74', background: '#fff', color: '#c2410c', font: 'inherit', fontSize: '11px', fontWeight: 700, cursor: 'pointer' }}>편집 초기화</button></div>}
                <div style={{ opacity: editCand && editRects[editCand.id] ? 0.45 : 1, pointerEvents: editCand && editRects[editCand.id] ? 'none' : 'auto' }}>
                <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', marginBottom: '6px' }}>배경 처리</div>
                <div style={{ display: 'flex', gap: '6px' }}>{BG_OPTS.map((o) => <button key={o.id} onClick={() => setBgMode(o.id)} style={{ flex: 1, padding: '7px 4px', borderRadius: '8px', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: 'pointer', border: bgMode === o.id ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: bgMode === o.id ? '#eef2ff' : '#fff', color: bgMode === o.id ? '#4E4CDB' : '#6b6b78' }}>{o.label}</button>)}</div>
                <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', margin: '14px 0 6px' }}>배치 우선순위</div>
                <div style={{ display: 'flex', gap: '6px' }}>{[{ id: 'A', t: 'A · 실루엣' }, { id: 'B', t: 'B · 인식' }].map((o) => <button key={o.id} onClick={() => setScorer((s) => ({ ...s, siloMode: o.id }))} style={{ flex: 1, padding: '7px 4px', borderRadius: '8px', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: 'pointer', border: scorer.siloMode === o.id ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: scorer.siloMode === o.id ? '#eef2ff' : '#fff', color: scorer.siloMode === o.id ? '#4E4CDB' : '#6b6b78' }}>{o.t}</button>)}</div>
                <MiniSlider label="목표 밀도" value={scorer.targetDensity} min={0.4} max={1} step={0.01} fmt={(v) => Math.round(v * 100) + '%'} onChange={(v) => setScorer((s) => ({ ...s, targetDensity: v }))} hint="제품 크기 — 세이프를 얼마나 채울지 (겹침과 무관)" />
                <MiniSlider label="겹침 허용치" value={scorer.overlapTol} min={0} max={0.35} step={0.01} fmt={(v) => Math.round(v * 100) + '%'} onChange={(v) => setScorer((s) => ({ ...s, overlapTol: v }))} hint="이만큼까지 제품 겹침 허용" />
              </div></div>}
          </section>
        </aside>

        {/* ── 중앙: 미리보기 ── */}
        <main className="ap-main">
          {/* 레이아웃 유형 칩 — 이미지가 있을 때 캔버스 상단에 sticky 오버레이 */}
          {(srcs.length > 0 || bannerType === 'E') && <div style={{ position: 'sticky', top: 0, zIndex: 10, display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center', padding: '8px 0 10px', background: 'linear-gradient(to bottom, rgba(246,246,250,0.97) 75%, rgba(246,246,250,0))', marginBottom: '4px', pointerEvents: 'none' }}>
            {BANNER_TYPES.filter((t) => t.ready !== false).map((t) => {
              const on = bannerType === t.id;
              return <button key={t.id} type="button" onClick={() => chooseBannerType(t.id)} style={{ padding: '4px 14px', fontSize: '12px', fontWeight: on ? 700 : 500, font: 'inherit', border: '1.5px solid ' + (on ? '#4E4CDB' : 'rgba(0,0,0,0.13)'), borderRadius: '20px', cursor: 'pointer', background: on ? '#4E4CDB' : 'rgba(255,255,255,0.9)', color: on ? '#fff' : '#43434e', transition: 'background .12s,color .12s,border-color .12s', whiteSpace: 'nowrap', boxShadow: on ? '0 2px 8px rgba(78,76,219,0.18)' : '0 1px 3px rgba(0,0,0,0.07)', pointerEvents: 'auto' }}>{t.label}</button>;
            })}
            <label style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '12px', fontWeight: 600, color: '#8a8a96', cursor: 'pointer', pointerEvents: 'auto', userSelect: 'none', whiteSpace: 'nowrap' }}>
              <input type="checkbox" checked={guideOn} onChange={(e) => setGuideOn(e.target.checked)} style={{ accentColor: '#4E4CDB', width: 13, height: 13, cursor: 'pointer' }} />
              여백
            </label>
          </div>}
          {!typeReady ?
          <div className="ap-empty"><div className="ap-empty-ic"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3" /><path d="M12 8v8M8 12h8" /></svg></div><p className="ap-empty-t">{BANNER_TYPES.find((t) => t.id === bannerType).label} — 준비 중</p><p className="ap-empty-s">이 유형의 배치 엔진은 곧 추가됩니다. 지금은 <b>A 누끼형</b>이 동작해요.</p></div> :
          layoutType === 'E' ?
          eReady ? <TypedResult onDownload={downloadPNG} p={{
            title: '보험 · ' + (eModel ? '로고+보험명+인물누끼' : '로고+보험명'),
            subtitle: <>로고 좌상단 · 보험명 · {eModel ? '인물누끼 우측 상체(최전면)' : '일러스트 중앙'}{deco ? ' · 부가정보 좌하단' : ''}</>,
            primary: { cand: {}, rankLabel: '보험', rankNum: 1, name: eModel ? '보험로고+인물누끼형' : '보험로고형', desc: '가이드 고정 배치', showMetrics: false, edit: { onUndo: focalUndo, onRedo: focalRedoDo, onReset: resetAll, onApplyGuide: applyGuideRules, canUndo: focalHist.length > 0, canRedo: focalRedo.length > 0 },
              stage: <EStage name={eName} illust={eIllust} illustContain={eIllustContain} model={eModel} focal={eFocalOf()} onFocal={setEFocal} onBegin={focalBegin} logos={logoMeta} deco={deco} decoPos={decoPos} onDecoPosChange={setDecoPosPreserved} onDecoScale={setDecoScalePreserved} decoSelected={decoSelected} onSelectDeco={onSelectDeco} onDeleteIllust={eIllust ? () => removeSourcesPreservePlacement([eIllust.src || eIllust.url]) : undefined} onDeleteModel={eModel ? () => removeSourcesPreservePlacement([eModel.src || eModel.url]) : undefined} bg={eBgStyle} textColor={eText} dispW={RESULT_DISP} guideOn={guideOn} selLogoIdx={selLogoIdx} onSelectLogo={onSelectLogo} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} />,
              logoCtrl: <LogoSizeSlider logos={logoMeta} selIdx={selLogoIdx} onScale={onLogoScale} /> },
            showRanking: false,
            hint: <>로고·보험명은 좌상단 고정입니다. {eModel ? '인물 누끼는 우측 상체 중심으로 최전면 합성됩니다. ' : ''}부가 이미지(선택)는 좌하단에 배치됩니다. (분할·풀이미지 여행 배너는 C·D 유형을 쓰세요.)</> }} /> :
          <div className="ap-empty"><div className="ap-empty-ic"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3" /><path d="M7 8h10M7 12h7" /></svg></div><p className="ap-empty-t">보험 배너</p><p className="ap-empty-s">우측에서 <b>로고</b>를 올리고 좌측 <b>보험명</b>을 입력하세요. 일러스트/모델 이미지는 선택입니다.</p></div> :
          srcs.length === 0 ?
          <div className="ap-empty droppable" onClick={() => upRef.current.click()} onDragOver={(e) => {e.preventDefault();e.currentTarget.classList.add('over');}} onDragLeave={(e) => e.currentTarget.classList.remove('over')} onDrop={(e) => {e.preventDefault();e.currentTarget.classList.remove('over');addImages(e.dataTransfer.files);}}><div className="ap-empty-ic"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3" /><path d="M3 15l5-5 4 4 3-3 6 6" /></svg></div><p className="ap-empty-t">이미지를 올려주세요</p><p className="ap-empty-s">상품·모델을 한 곳에 올리면 자동 분류 → 카테고리 감지 → 후보가 생성됩니다.</p><button className="ap-btn primary" onClick={(e) => {e.stopPropagation();upRef.current.click();}}>이미지 업로드</button></div> :
          layoutType === 'D' ?
          fullPrimary ? <TypedResult onDownload={downloadPNG} p={{
            title: '풀이미지형',
            subtitle: <><b>{fullCands.length}개 후보</b> · 화보 1장 풀블리드 · 초점 {fullPrimary.metrics.focal}</>,
            primary: { cand: fullPrimary, name: fullPrimary.strategyLabel, desc: fullPrimary.rowsLabel + ' · 드래그 초점 · 모서리 크기', recBadge: true, edit: { ...focalEdit, onReset: resetAll, onApplyGuide: applyGuideRules },
              stage: <FullStage url={fullPrimary.url} source={fullPrimary.src || fullPrimary.url} overflow={fullPrimary.overflow} focal={fullFocalOf(fullPrimary)} onFocal={(f) => setFullFocal(fullPrimary.id, f)} onBegin={focalBegin} dispW={RESULT_DISP} logos={logoMeta} deco={deco} decoPos={decoPos} onDecoPosChange={setDecoPosPreserved} onDecoScale={setDecoScalePreserved} decoSelected={decoSelected} onSelectDeco={onSelectDeco} onDeleteImage={() => removeSourcesPreservePlacement([fullPrimary.src || fullPrimary.url])} guideOn={guideOn} selLogoIdx={selLogoIdx} onSelectLogo={onSelectLogo} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} />,
              logoCtrl: <LogoSizeSlider logos={logoMeta} selIdx={selLogoIdx} onScale={onLogoScale} /> },
            secondary: fullSecondary && { cand: fullSecondary, name: fullSecondary.strategyLabel, desc: fullSecondary.rowsLabel, onMakePrimary: () => setFullPinned(fullSecondary.id),
              stage: <FullStage url={fullSecondary.url} source={fullSecondary.src || fullSecondary.url} overflow={fullSecondary.overflow} focal={fullFocalOf(fullSecondary)} dispW={RESULT_DISP} logos={logoMeta} deco={deco} decoPos={decoPos} guideOn={guideOn} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} /> },
            cands: fullCands, activeId: fullPrimaryId, onPick: setFullPinned, showRanking: true,
            thumb: (c) => <img src={c.url} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: (c.focal ? c.focal.x : 50) + '% ' + (c.focal ? c.focal.y : 50) + '%' }} />,
            hint: <>사진을 <b>드래그</b>해 초점을 맞추고, 우하단 <b>모서리 핸들</b>을 드래그해 크기를 조정하세요. 아래 <b>후보 랭킹</b>에서 다른 초점을 고를 수도 있어요. 점선 = 세이프(핵심 콘텐츠 권장 영역).</> }} /> :
          <div className="ap-empty"><TSpinner size={26} /><p className="ap-empty-t" style={{ marginTop: '14px' }}>화보를 불러오는 중…</p><p className="ap-empty-s">사진을 550×550에 꽉 채우고 초점 시안을 구성합니다.</p></div> :
          layoutType === 'C' ?
          fullItems.length < 2 ?
          <div className="ap-empty"><div className="ap-empty-ic"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3" /><path d="M12 3v18M3 12h18" /></svg></div><p className="ap-empty-t">분할형은 사진 2장 이상</p><p className="ap-empty-s">2장 → 2분할 · 3장 → 3분할 · 4장 → 4분할로 자동 구성됩니다. (현재 {fullItems.length}장)</p></div> :
          splitPrimary ? <TypedResult onDownload={downloadPNG} p={{
            title: splitPrimary.strategyLabel,
            subtitle: <><b>{splitCands.length}개 후보</b> · {splitPrimary.metrics.panels}칸 높이 균등{fullItems.length > 4 ? ' · +' + (fullItems.length - 4) + '장은 미사용' : ''}</>,
            primary: { cand: splitPrimary, name: splitPrimary.rowsLabel, desc: '콘텐츠 높이 균등 · 폭은 비율 자동', recBadge: true, edit: { ...focalEdit, onReset: resetAll, onApplyGuide: applyGuideRules },
              extraActions: splitCands.length > 1 && <><button className="ap-btn ghost" onClick={() => pickSplitOffset(-1)}>이전 배치</button><span style={{ alignSelf: 'center', fontSize: '12px', fontWeight: 700, color: '#6b6b78', padding: '0 2px' }}>배치 {(splitActiveIndex >= 0 ? splitActiveIndex : 0) + 1}/{splitCands.length}</span><button className="ap-btn ghost" onClick={() => pickSplitOffset(1)}>다음 배치</button></>,
              stage: <SplitStage panels={splitPrimary.panels} focalOf={(i, base) => splitFocalOf(splitPrimary.id, i, base)} onFocal={(i, f) => setSplitFocal(splitPrimary.id, i, f)} onSwap={(from, to) => swapSplitPanels(splitPrimary.id, from, to)} onBegin={focalBegin} dispW={RESULT_DISP} logos={logoMeta} onDeletePanel={(panel) => removeSourcesPreservePlacement([panel.src || panel.url])} guideOn={guideOn} selLogoIdx={selLogoIdx} onSelectLogo={onSelectLogo} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} />,
              logoCtrl: <LogoSizeSlider logos={logoMeta} selIdx={selLogoIdx} onScale={onLogoScale} /> },
            secondary: splitSecondary && { cand: splitSecondary, name: splitSecondary.rowsLabel, desc: splitSecondary.strategyLabel, onMakePrimary: () => setSplitPinned(splitSecondary.id),
              stage: <SplitStage panels={splitSecondary.panels} focalOf={(i, base) => base} dispW={RESULT_DISP} logos={logoMeta} guideOn={guideOn} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} /> },
            cands: splitCands, activeId: splitPrimaryId, onPick: setSplitPinned, showRanking: true,
            thumb: (c) => c.panels.map((pn, k) => <img key={k} src={pn.url} alt="" draggable={false} style={{ position: 'absolute', left: pn.x / 550 * 100 + '%', top: pn.y / 550 * 100 + '%', width: pn.w / 550 * 100 + '%', height: pn.h / 550 * 100 + '%', objectFit: 'cover', objectPosition: (pn.focal ? pn.focal.x : 50) + '% ' + (pn.focal ? pn.focal.y : 50) + '%' }} />),
            hint: <>각 칸을 <b>드래그</b>해 초점을 맞추세요. 아래 <b>후보 랭킹</b>에서 다른 분할·배치를 고를 수 있어요. (세로로 긴 사진은 긴 칸에 자동 배치)</> }} /> :
          <div className="ap-empty"><TSpinner size={26} /><p className="ap-empty-t" style={{ marginTop: '14px' }}>분할 시안을 구성하는 중…</p></div> :
          layoutType === 'B' ?
          bBusy ?
          <div className="ap-empty"><TSpinner size={26} /><p className="ap-empty-t" style={{ marginTop: '14px' }}>누끼·화보를 판별하는 중…</p><p className="ap-empty-s">투명 배경=상품(좌 클러스터), 사진=화보(우 패널)로 자동 분류합니다.</p></div> :
          !bNukki.length || !bPhoto ?
          <div className="ap-empty"><div className="ap-empty-ic"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3" /><path d="M12 3v18" /></svg></div><p className="ap-empty-t">분할+누끼형은 누끼 상품 + 화보가 필요해요</p><p className="ap-empty-s">투명 배경 <b>상품(누끼)</b> 1장 이상 + 일반 <b>사진(화보)</b> 1장을 올려주세요. (현재 누끼 {bNukki.length} · 화보 {bPhoto ? 1 : 0})</p></div> :
          bPrimary ? <TypedResult onDownload={downloadPNG} p={{
            title: '분할+누끼형',
            subtitle: <><b>{bCands.length}개 후보</b> · 좌 누끼 상품 3/5 + 우 화보 2/5</>,
            primary: { cand: bPrimary, name: bPrimary.strategyLabel || '분할+누끼', desc: bPrimary.rowsLabel + ' · 제품 드래그·크기 · 우 분할컷 고정 · 이미지 확대/초점', recBadge: true, edit: { onUndo: bUndo, onRedo: bRedo, onReset: resetAll, onApplyGuide: applyGuideRules, canUndo: history.length > 0 || focalHist.length > 0, canRedo: redoList.length > 0 || focalRedo.length > 0 },
              stage: <SplitNukkiStage rects={rectsFor(bPrimary)} editable selectedId={selectedId} onSelect={onSelectProduct} onChange={onPrimaryChange} onBegin={beginEdit} bounds={bPrimary.bounds} scorer={scorer} photo={bPhoto} focal={bFocalOf()} onFocal={setBFocal} split={B_PHOTO.x} dispW={RESULT_DISP} logos={logoMeta} onDeleteItem={onCanvasDeleteItem} onDeletePhoto={() => { if (bPhoto) removeSourcesPreservePlacement([bPhoto.src]); }} guideOn={guideOn} selLogoIdx={selLogoIdx} onSelectLogo={onSelectLogo} onFocalBegin={focalBegin} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} onSelectionChange={setSelectedIds} />,
              logoCtrl: <LogoSizeSlider logos={logoMeta} selIdx={selLogoIdx} onScale={onLogoScale} /> },
            secondary: bSecondary && { cand: bSecondary, name: bSecondary.strategyLabel || '분할+누끼', desc: bSecondary.rowsLabel, onMakePrimary: () => setBPinned(bSecondary.id),
              stage: <SplitNukkiStage rects={bSecondary.rects} editable={false} photo={bPhoto} focal={bFocalOf()} split={B_PHOTO.x} dispW={RESULT_DISP} logos={logoMeta} guideOn={guideOn} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} /> },
            cands: bCands, activeId: bPrimaryId, onPick: setBPinned, showRanking: true,
            thumb: (c) => <>{bPhoto && <img src={bPhoto.url} alt="" draggable={false} style={{ position: 'absolute', left: B_PHOTO.x / 550 * 100 + '%', top: 0, width: (550 - B_PHOTO.x) / 550 * 100 + '%', height: '100%', objectFit: 'cover' }} />}{[...c.rects].filter((r) => r.role !== 'model').sort((a, b) => a.z - b.z).map((r, k) => <img key={k} src={r.url} alt="" draggable={false} style={{ position: 'absolute', left: r.x / 550 * 100 + '%', top: r.y / 550 * 100 + '%', width: r.w / 550 * 100 + '%', height: r.h / 550 * 100 + '%', objectFit: 'contain' }} />)}</>,
            hint: <>좌측은 <b>누끼 상품 배열</b>(아래 <b>후보 랭킹</b>에서 선택), 우측 <b>분할컷 위치는 고정</b>입니다. 화보 이미지는 이동 없이 휠 또는 우하단 핸들로 확대/축소만 가능합니다.</> }} /> :
          <div className="ap-empty"><TSpinner size={26} /><p className="ap-empty-t" style={{ marginTop: '14px' }}>배치 시안을 구성하는 중…</p></div> :
          busy || catBusy || bannerType === 'A' && !productItems.length && modelItems.length > 0 && modelItems.some((it) => it.src && faceData[it.src] === undefined && !headChinData[it.src]) ?
          <div className="ap-empty"><TSpinner size={26} /><p className="ap-empty-t" style={{ marginTop: '14px' }}>{busy ? '배경 제거 · 콘텐츠 측정 중…' : '이미지 속성을 분석하고 있어요…'}</p><p className="ap-empty-s">상품/모델을 분류하고 속성·형태에 맞춰 후보를 구성합니다.</p></div> :
          productItems.length === 0 ?
          fashionCand ? <FashionResult cand={fashionCand} rects={rectsFor(fashionCand)} cands={fashionCands} activeId={fashionPrimaryId} onPick={setPinnedId} logos={logoMeta} deco={deco} decoPos={decoPos} onDecoPosChange={setDecoPosPreserved} onDecoScale={setDecoScalePreserved} decoSelected={decoSelected} onSelectDeco={onSelectDeco} selectedId={selectedId} onSelect={onSelectProduct} onChange={onPrimaryChange} onReset={resetAll} onApplyGuide={applyGuideRules} onDownload={downloadPNG} onBegin={beginEdit} onUndo={undo} canUndo={history.length > 0} onRedo={redo} canRedo={redoList.length > 0} onDeleteItem={onCanvasDeleteItem} guideOn={guideOn} selLogoIdx={selLogoIdx} onSelectLogo={onSelectLogo} onLogoScale={onLogoScale} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} onSelectionChange={setSelectedIds} /> :
          <div className="ap-empty"><p className="ap-empty-t">모델 이미지를 인식했어요</p><p className="ap-empty-s">레이아웃을 구성하는 중입니다…</p></div> :
          <>
                    <div className="ap-results">{primary && <APResultCard cand={primary} rects={rectsFor(primary)} metrics={metricsFor(primary)} rank={rankOf(primary.id)} primary selectedId={selectedId} onSelect={onSelectProduct} onChange={onPrimaryChange} onReset={resetAll} onApplyGuide={applyGuideRules} onDownload={downloadPNG} scorer={scorer} onBegin={beginEdit} onUndo={undo} canUndo={history.length > 0} onRedo={redo} canRedo={redoList.length > 0} count={nukkiPatternItems.length} logos={logoMeta} deco={deco} decoPos={decoPos} onDecoPosChange={setDecoPosPreserved} onDecoScale={setDecoScalePreserved} decoSelected={decoSelected} onSelectDeco={onSelectDeco} onDeleteItem={onCanvasDeleteItem} guideOn={guideOn} selLogoIdx={selLogoIdx} onSelectLogo={onSelectLogo} onLogoScale={onLogoScale} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} onSelectionChange={setSelectedIds} />}</div>
                    {(() => {
                      const NP = window.NukkiPattern;
                      const LABELS = (NP && NP.PATTERN_LABELS) || { box: '박스형', trap: '사다리꼴', bundle: '묶음' };
                      const tabs = [{ id: 'all', label: '전체', list: ranked }].concat(['box', 'trap', 'bundle'].map((id) => ({ id, label: LABELS[id], list: NP ? NP.listByPattern(ranked, id) : [] })));
                      const shown = (NP && NP.listForTab(ranked, rankTab)) || ranked;
                      return (
                        <div className="ap-rankstrip">
                          <div className="ap-rankstrip-head"><b>후보 랭킹</b><span>{shown.length}개 · {rankTab === 'all' ? '전체 점수순' : '유형 내 점수순'}</span></div>
                          <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', margin: '-2px 0 10px' }}>
                            {tabs.map((t) => {
                              const on = rankTab === t.id;
                              const empty = t.id !== 'all' && !t.list.length;
                              return (
                                <button key={t.id} type="button" disabled={empty} onClick={() => setRankTab(t.id)}
                                  title={empty ? '이 이미지로는 ' + t.label + ' 후보가 없어요' : ''}
                                  style={{ padding: '5px 10px', borderRadius: '999px', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: empty ? 'not-allowed' : 'pointer', border: on ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: on ? '#eef2ff' : '#fff', color: on ? '#4E4CDB' : (empty ? '#9a9aa6' : '#6b6b78'), opacity: empty ? 0.55 : 1 }}>
                                  {t.label} {t.list.length}
                                </button>);
                            })}
                          </div>
                          <div className="ap-rankstrip-row">{shown.map((c, i) => <APMiniCanvas key={c.id} cand={c} rank={i + 1} active={c.id === primaryId} onClick={() => setPinnedId(c.id)} />)}</div>
                        </div>);
                    })()}
                    <p className="ap-hint">1순위(굵은 테두리)에서 제품을 클릭해 선택하고, <b>드래그로 이동 · 모서리 핸들로 크기</b>를 조정하세요. 방향키(⇧=10px) 이동, ⌘Z 되돌리기도 됩니다.</p>
                  </>}
        </main>

        {/* ── 우측: 선택사항 인스펙터 ── */}
        <aside className="ap-controls" style={{ borderLeft: '1px solid #ececf2', borderRight: 'none' }}>
          <div style={{ fontSize: '11px', fontWeight: 800, color: '#9a9aa6', letterSpacing: '0.04em', marginBottom: '10px', display: 'flex', alignItems: 'center', gap: '6px' }}>선택 항목 {!required && <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><rect x="5" y="11" width="14" height="9" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></svg>}</div>
          {!required ?
          <div style={{ fontSize: '12px', color: '#9a9aa6', lineHeight: 1.6, padding: '8px 2px' }}>필수 항목에서 이미지를 업로드 하면,
선택 항목(로고·부가정보 등)이 활성화 됩니다.</div> : <>
              {window.BannerImageTools && window.BannerImageTools.OutpaintPanel && React.createElement(window.BannerImageTools.OutpaintPanel, { source: outpaintSource, initialVariants: outpaintVariants, onVariantsChange: setOutpaintVariants, onApply: applyOutpaintVariant })}
              {window.BannerImageTools && React.createElement(window.BannerImageTools.Panel, {
                value: activeImageAdjust,
                onChange: applyImageAdjust,
                disabled: !srcs.length,
              })}
              {/* 로고 (독립) */}
              <section className="ap-sec">
                <div className="ap-sec-head" style={{ margin: '0 0 8px' }}>로고<span className="ap-opt">가이드 승인 2 · 확장 편집 3 · 우측 상단 · 절반선 침범 방지</span></div>
                <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'flex-start' }}>
                  {logoSrcs.map((s, i) => {
                    const meta = logoMeta[i] || {};
                    const orient = logoOrient550(meta);
                    const sw = logoScaleW550(meta), sh = logoScaleH550(meta);
                    const onDropLogo = (e) => {e.currentTarget.classList.remove('drop-over');const d = e.dataTransfer.getData('text/plain');if (d.indexOf('logo:') !== 0) return;e.preventDefault();const from = parseInt(d.slice(5), 10);if (isNaN(from) || from === i) return;preserveCurrentPlacementForOverlay();setLogoSrcs((p) => {const n = p.slice();const mv = n.splice(from, 1)[0];n.splice(i, 0, mv);return n;});};
                    const rowStyle = { display: 'flex', alignItems: 'center', gap: '5px', marginTop: '5px' };
                    const rangeStyle = { flex: 1, minWidth: 0, accentColor: '#4E4CDB' };
                    return (
                      <div key={i} data-logo-card="550" onDragOver={(e) => {if (String(e.dataTransfer.types).indexOf('text/plain') < 0) return;e.preventDefault();e.dataTransfer.dropEffect = 'move';e.currentTarget.classList.add('drop-over');}} onDragLeave={(e) => e.currentTarget.classList.remove('drop-over')} onDrop={onDropLogo} style={{ width: '118px', padding: '6px', border: selLogoIdx === i ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', borderRadius: '10px', background: selLogoIdx === i ? '#f7f8ff' : '#fff', boxSizing: 'border-box' }}>
                        <div draggable onClick={() => onSelectLogo(i)} onDragStart={(e) => {e.dataTransfer.effectAllowed = 'move';e.dataTransfer.setData('text/plain', 'logo:' + i);}} style={{ position: 'relative', width: '100%', height: '46px', border: '1px solid #e7e7ee', borderRadius: '8px', overflow: 'hidden', background: '#fafafb', cursor: 'grab' }}>
                          <img src={s} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />
                          <button onClick={(e) => {e.stopPropagation();preserveCurrentPlacementForOverlay();setLogoSrcs((p) => p.filter((_, k) => k !== i));}} title="로고 삭제" style={{ position: 'absolute', top: '2px', right: '2px', width: '16px', height: '16px', borderRadius: '5px', border: 'none', background: 'rgba(28,28,34,.78)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg></button>
                        </div>
                        <div role="group" aria-label={'로고 ' + (i + 1) + ' 유형'} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px', marginTop: '6px' }}>
                          {[{ id: 'h', label: '가로형' }, { id: 'v', label: '세로형' }].map((opt) => {
                            const on = orient === opt.id;
                            return <button key={opt.id} type="button" onClick={() => setLogoOrient550(i, opt.id)} style={{ minWidth: 0, padding: '5px 0', borderRadius: '7px', border: on ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: on ? '#eef2ff' : '#fff', color: on ? '#4E4CDB' : '#6b6b78', font: 'inherit', fontSize: '10.5px', fontWeight: 800, cursor: 'pointer', lineHeight: 1.1 }}>{opt.label}</button>;
                          })}
                        </div>
                        <div data-logo-size-controls="550" style={{ marginTop: '6px' }}>
                          <label style={rowStyle}><span style={{ width: '24px', fontSize: '10.5px', fontWeight: 800, color: '#6b6b78' }}>가로</span><input aria-label={'로고 ' + (i + 1) + ' 가로 크기'} type="range" min="0.25" max="3" step="0.05" value={sw} onChange={(e) => {onLogoScale(i, 'w', parseFloat(e.target.value));setSelLogoIdx(i);}} style={rangeStyle} /><b style={{ width: '32px', textAlign: 'right', fontSize: '10.5px', color: '#1c1c22' }}>{Math.round(sw * 100)}%</b></label>
                          <label style={rowStyle}><span style={{ width: '24px', fontSize: '10.5px', fontWeight: 800, color: '#6b6b78' }}>세로</span><input aria-label={'로고 ' + (i + 1) + ' 세로 크기'} type="range" min="0.25" max="3" step="0.05" value={sh} onChange={(e) => {onLogoScale(i, 'h', parseFloat(e.target.value));setSelLogoIdx(i);}} style={rangeStyle} /><b style={{ width: '32px', textAlign: 'right', fontSize: '10.5px', color: '#1c1c22' }}>{Math.round(sh * 100)}%</b></label>
                        </div>
                      </div>);
                  })}
                  {logoSrcs.length < LOGO_550_MAX_COUNT && <button className="ap-add-dash" onClick={() => logoRef.current.click()} {...dz(addLogos)} style={{ width: '64px', height: '40px', border: '1.5px dashed #cfcfda', borderRadius: '8px', background: '#fff', cursor: 'pointer', color: '#8a8a96', fontSize: '18px' }}>+</button>}
                  {window.AssetLibraryField && <AssetLibraryField kind="logo" max={LOGO_550_MAX_COUNT} onApply={applyLibraryLogos} compact />}
                </div>
              </section>

              {/* [보험] 텍스트·배경 — 선택항목(우측)에 배치 */}
              {bannerType === 'E' && <section className="ap-sec">
                <div className="ap-sec-head" style={{ margin: '0 0 8px' }}>보험명<span className="ap-opt">로고 아래 · 가로 중앙 · 줄바꿈 가능</span></div>
                <textarea value={eName} onChange={(e) => setEName(e.target.value)} placeholder={'예: 프로미라이프\n참좋은 운전자 상해보험'} rows={2} style={{ width: '100%', padding: '9px 11px', borderRadius: '10px', border: '1px solid #e7e7ee', font: 'inherit', fontSize: '13px', lineHeight: 1.4, boxSizing: 'border-box', resize: 'vertical' }} />
                <div className="ap-sec-head" style={{ margin: '14px 0 8px', display: 'flex', alignItems: 'center', gap: '6px' }}>배경<span style={{ fontSize: '10px', fontWeight: 700, padding: '1px 6px', borderRadius: '5px', background: eBgChip == null ? '#eef2ff' : '#f0f0f3', color: eBgChip == null ? '#4E4CDB' : '#8a8a96' }}>{eBgChip == null ? '기본' : '수동'}</span>{eBgChip != null && <button onClick={() => setEBgChip(null)} style={{ marginLeft: 'auto', border: 'none', background: 'none', color: '#4E4CDB', fontSize: '10.5px', fontWeight: 700, cursor: 'pointer', padding: 0 }}>기본으로</button>}</div>
                <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
                  <button onClick={() => setEBgChip(null)} title="기본색" style={{ width: '26px', height: '26px', borderRadius: '50%', background: E_CFG.bgDefault, cursor: 'pointer', padding: 0, boxSizing: 'border-box', border: eBgChip == null ? '2px solid #1c1c22' : '1px solid #e7e7ee' }} />
                  {(window.COLORCHIP_1E_TOP || []).map((c) => <button key={c.id} onClick={() => setEBgChip(c.hex)} title={c.label} style={{ width: '26px', height: '26px', borderRadius: '50%', background: c.hex, cursor: 'pointer', padding: 0, boxSizing: 'border-box', border: eBgChip === c.hex ? '2px solid #1c1c22' : '1px solid rgba(0,0,0,.08)' }} />)}
                  <button onClick={() => setEBgChip('#ffffff')} title="흰색" style={{ width: '26px', height: '26px', borderRadius: '50%', background: '#fff', cursor: 'pointer', padding: 0, boxSizing: 'border-box', border: eBgChip === '#ffffff' ? '2px solid #1c1c22' : '1px solid #e7e7ee' }} />
                  <label title="직접 색 선택" style={{ position: 'relative', width: '26px', height: '26px', borderRadius: '50%', cursor: 'pointer', boxSizing: 'border-box', border: eBgChip && !((window.COLORCHIP_1E_TOP || []).some((c) => c.hex === eBgChip)) && eBgChip !== '#ffffff' ? '2px solid #1c1c22' : '1px solid #e7e7ee', background: 'conic-gradient(#ff2d2d,#ffd23f,#4ade80,#38bdf8,#6366f1,#e252e2,#ff2d2d)', display: 'inline-block' }}><input type="color" value={eBgChip || E_CFG.bgDefault} onChange={(e) => setEBgChip(e.target.value)} style={{ position: 'absolute', left: 0, top: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', border: 'none', padding: 0, margin: 0 }} /></label>
                </div>
                <div style={{ fontSize: '11px', color: '#9a9aa6', marginTop: '6px' }}>기본색으로 깔립니다. 컬러칩·직접 선택으로 바꿀 수 있어요.</div>
              </section>}

              {/* 택1 그룹 — 누끼형(A)·풀이미지형(D)은 부가정보 4종, 보험형(E)은 부가 이미지 1종만 허용. */}
              {(bannerType === 'A' || bannerType === 'D' || bannerType === 'E') &&
              <section className="ap-sec">
                <div className="ap-sec-head" style={{ margin: '0 0 8px' }}>부가 정보<span className="ap-opt">{bannerType === 'E' ? '부가 이미지 · 다시 눌러 해제' : '4종 중 1개 · 다시 눌러 해제'}</span></div>
                <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
                  {(bannerType === 'E' ? EXCLUSIVE.filter((k) => k.id === 'image') : EXCLUSIVE.filter((k) => k.id !== 'mark')).map((k) => {const on = exSel === k.id;return <React.Fragment key={k.id}><button onClick={() => pickExclusive(k.id)} style={{ display: 'flex', alignItems: 'center', gap: '10px', textAlign: 'left', padding: '9px 11px', borderRadius: '9px', font: 'inherit', cursor: 'pointer', border: on ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: on ? '#eef2ff' : '#fff' }}><span style={{ flex: '0 0 auto', width: '18px', height: '18px', borderRadius: '50%', border: on ? '1.5px solid #4E4CDB' : '1.5px solid #cfcfda', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{on && <span style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#4E4CDB' }} />}</span><span style={{ flex: 1, minWidth: 0 }}><b style={{ fontSize: '12.5px', color: on ? '#4E4CDB' : '#1c1c22', display: 'block' }}>{k.label}</b><span style={{ fontSize: '10.5px', color: '#9a9aa6' }}>{k.hint}</span></span>{on && <span style={{ flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: '3px', fontSize: '10.5px', fontWeight: 700, color: '#4E4CDB' }}><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>해제</span>}</button>
                  {on && <div style={{ marginTop: '8px', padding: '12px', background: '#f6f6fb', border: '1px solid #e7e7ee', borderLeft: '3px solid #4E4CDB', borderRadius: '10px' }}>
                <div style={{ fontSize: '10.5px', fontWeight: 800, color: '#4E4CDB', letterSpacing: '0.02em', marginBottom: '2px' }}>{(EXCLUSIVE.find((k) => k.id === exSel) || {}).label} 세부 설정</div>
                {exSel === 'text' &&
              <div style={{ marginTop: '12px' }}>
                    <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', marginBottom: '6px' }}>예시</div>
                    <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '12px' }}>
                      {TEXT_PRESETS.map((pr) => <button key={pr} onClick={() => setAddText(pr)} style={{ padding: '5px 10px', borderRadius: '999px', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: 'pointer', border: addText === pr ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: addText === pr ? '#eef2ff' : '#fff', color: addText === pr ? '#4E4CDB' : '#43434e' }}>{pr.split(NL).join(' ')}</button>)}
                    </div>
                    <div style={{ display: 'flex', gap: '6px' }}>
                      <input value={addText.split(NL).join(' ')} onChange={(e) => setAddText(e.target.value)} placeholder="문구 입력 (최대 3줄 / 8자)" style={{ flex: 1, minWidth: 0, padding: '9px 10px', borderRadius: '9px', border: '1px solid #e7e7ee', font: 'inherit', fontSize: '12.5px', boxSizing: 'border-box' }} />
                      <button onClick={autoFormatText} disabled={textBusy || !addText.trim()} title="의미 단위로 줄바꿈 정리" style={{ padding: '0 11px', borderRadius: '9px', border: '1px solid #e7e7ee', background: '#fff', cursor: textBusy || !addText.trim() ? 'default' : 'pointer', font: 'inherit', fontSize: '12px', fontWeight: 700, color: '#4E4CDB', display: 'flex', alignItems: 'center', gap: '5px', whiteSpace: 'nowrap' }}>{textBusy ? <TSpinner size={13} /> : <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.3 6.2L21.5 11l-6.2 1.8L13 19l-2.3-6.2L4.5 11l6.2-1.8z" /></svg>}자동</button>
                    </div>
                    {(() => {const pb = parseBadge(addText);const lc = pb.kind === 'lines' ? pb.lines.length : pb.kind === 'empty' ? 0 : 1;const n = textVisibleLen(addText);const over = n > 8 || lc > 3;return <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '6px', fontSize: '11px', color: over ? '#c0451f' : '#9a9aa6' }}><span>{Math.max(1, lc)}줄 · {n}/8자</span>{over ? <span>가이드 초과 — 최대 3줄/8자</span> : <span>자동으로 유형을 맞춰 렌더합니다</span>}</div>;})()}
                    <div style={{ display: 'flex', justifyContent: 'center', marginTop: '12px' }}><AddTextBadge text={addText} size={130} /></div>
                  </div>}
                {exSel === 'image' &&
              <div style={{ marginTop: '12px' }}>
                    <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', marginBottom: '6px' }}>표기 유형</div>
                    <div style={{ display: 'flex', gap: '6px', marginBottom: '12px' }}>
                      {ADD_IMG_TYPES.map((t) => <button key={t.id} onClick={() => {preserveCurrentPlacementForOverlay();if (t.id !== addImgType) {setAddImgType(t.id);setAddImgTitle(t.label);} else {setAddImgType(t.id);}}} style={{ flex: 1, padding: '7px 4px', borderRadius: '8px', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: 'pointer', border: addImgType === t.id ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: addImgType === t.id ? '#eef2ff' : '#fff', color: addImgType === t.id ? '#4E4CDB' : '#6b6b78' }}>{t.label}</button>)}
                    </div>
                    <div style={{ marginBottom: '10px' }}>
                      <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', marginBottom: '6px' }}>{addImgType} 표기</div>
                      <input value={addImgTitle} onChange={(e) => {preserveCurrentPlacementForOverlay();setAddImgTitle(e.target.value.slice(0, 12));}} placeholder={addImgType} aria-label={addImgType + ' 상단 타이틀'} style={{ width: '100%', boxSizing: 'border-box', padding: '8px 10px', borderRadius: '8px', border: '1px solid #e7e7ee', font: 'inherit', fontSize: '12px' }} />
                    </div>
                    <div style={{ display: 'flex', justifyContent: 'center' }}><AddImageBadge type={addImgType} title={addImgTitle} src={addImgSrc} onPick={() => addImgRef.current.click()} onClear={clearAddImg} onDropFiles={setAddImg} kind={addImgKind} pos={addImgPos} onPosChange={setAddImgPosPreserved} /></div>
                    <p style={{ fontSize: '11px', color: '#9a9aa6', marginTop: '10px', textAlign: 'center' }}>상단 보라 헤더 + 아래 영역에 업로드 이미지가 들어갑니다.</p>
                  </div>}
                {exSel === 'color' &&
              <div style={{ marginTop: '12px' }}>
                    <input ref={chipImgRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => {addChipFromImage(e.target.files);e.target.value = '';}} />
                    <div style={{ display: 'flex', gap: '6px', marginBottom: '10px' }}>
                      {[{ id: 'hex', label: '헥사코드' }, { id: 'image', label: '이미지에서 추출' }].map((m) => <button key={m.id} onClick={() => setChipMethod(m.id)} style={{ flex: 1, padding: '7px 4px', borderRadius: '8px', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: 'pointer', border: chipMethod === m.id ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: chipMethod === m.id ? '#eef2ff' : '#fff', color: chipMethod === m.id ? '#4E4CDB' : '#6b6b78' }}>{m.label}</button>)}
                      <button type="button" onClick={pickChipFromScreen} disabled={!window.EyeDropper || chipColors.length >= CHIP_MAX} title={window.EyeDropper ? '화면에서 색을 직접 선택' : 'Chrome/Edge 등 EyeDropper 지원 브라우저에서 사용 가능'} style={{ flex: 1, padding: '7px 4px', borderRadius: '8px', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: window.EyeDropper && chipColors.length < CHIP_MAX ? 'pointer' : 'default', border: '1px solid #e7e7ee', background: '#fff', color: '#4E4CDB', opacity: window.EyeDropper && chipColors.length < CHIP_MAX ? 1 : 0.48 }}>스포이드</button>
                    </div>
                    {chipMethod === 'hex' ?
                <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
                        <span style={{ width: '28px', height: '28px', borderRadius: '7px', flex: '0 0 auto', background: normHex(chipHexInput) || '#f0f0f3', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.08)' }} />
                        <input value={chipHexInput} onChange={(e) => setChipHexInput(e.target.value)} onKeyDown={(e) => {if (e.key === 'Enter') addChipHex();}} placeholder="#680ABA" maxLength={7} style={{ flex: 1, minWidth: 0, padding: '8px 10px', borderRadius: '8px', border: '1px solid #e7e7ee', font: 'inherit', fontSize: '12.5px', boxSizing: 'border-box' }} />
                        <button onClick={addChipHex} disabled={!normHex(chipHexInput) || chipColors.length >= CHIP_MAX} style={{ padding: '8px 12px', borderRadius: '8px', border: 'none', font: 'inherit', fontSize: '12px', fontWeight: 700, cursor: normHex(chipHexInput) && chipColors.length < CHIP_MAX ? 'pointer' : 'default', background: normHex(chipHexInput) && chipColors.length < CHIP_MAX ? '#4E4CDB' : '#e7e7ee', color: normHex(chipHexInput) && chipColors.length < CHIP_MAX ? '#fff' : '#9a9aa6' }}>추가</button>
                      </div> :
                <button onClick={() => chipImgRef.current.click()} {...dz(addChipFromImage)} disabled={chipBusy || chipColors.length >= CHIP_MAX} style={{ width: '100%', padding: '11px', borderRadius: '9px', border: '1.5px dashed #cfcfda', background: '#fff', cursor: chipBusy ? 'default' : 'pointer', font: 'inherit', fontSize: '12px', fontWeight: 700, color: '#6b6b78', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '7px' }}>{chipBusy ? <><TSpinner size={13} />대표색 추출 중…</> : '이미지 업로드 → 대표색 추출'}</button>}
                    <div style={{ marginTop: '12px' }}>
                      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '7px' }}><span style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e' }}>팔레트</span><span style={{ fontSize: '11px', color: '#9a9aa6' }}>{chipColors.length}/{CHIP_MAX}</span></div>
                      {chipColors.length === 0 ?
                  <p style={{ fontSize: '11.5px', color: '#9a9aa6', margin: 0 }}>색상을 추가하면 여기에 컬러칩이 쌓여요.</p> :
                  <div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px' }}>
                          {chipColors.map((c, i) => <div key={i + c} style={{ position: 'relative' }}><span style={{ display: 'block', width: '30px', height: '40px', borderRadius: '15px', background: c, boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.1)' }} /><button onClick={() => removeChip(i)} title={c} style={{ position: 'absolute', top: '-5px', right: '-5px', width: '17px', height: '17px', borderRadius: '50%', border: '1.5px solid #fff', background: '#1c1c22', color: '#fff', cursor: 'pointer', fontSize: '10px', lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>×</button><span style={{ display: 'block', textAlign: 'center', fontSize: '9px', color: '#9a9aa6', marginTop: '3px', letterSpacing: '-0.02em' }}>{c.replace('#', '').toUpperCase()}</span></div>)}
                        </div>}
                    </div>
                  </div>}
                {exSel === 'flag' &&
              <div style={{ marginTop: '10px' }}>
                    <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', marginBottom: '6px' }}>노출 개수 <span style={{ fontWeight: 500, color: '#9a9aa6' }}>· 디지털 상품 전용</span></div>
                    <div style={{ display: 'flex', gap: '6px' }}>{[1, 2].map((n) => <button key={n} onClick={() => setFlagCount(n)} style={{ flex: 1, padding: '7px 4px', borderRadius: '8px', font: 'inherit', fontSize: '12px', fontWeight: 700, cursor: 'pointer', border: flagCount === n ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: flagCount === n ? '#eef2ff' : '#fff', color: flagCount === n ? '#4E4CDB' : '#6b6b78' }}>{n}개</button>)}</div>
                    <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '10px' }}>
                      {normalizeFlagItems(flagItems).slice(0, flagCount).map((item, idx) => <div key={idx} style={{ border: '1px solid #ececf2', borderRadius: '9px', padding: '9px', background: '#fff' }}>
                        <input ref={flagImgRefs[idx]} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => {setFlagImg(idx, e.target.files);e.target.value = '';}} />
                        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '7px' }}><span style={{ fontSize: '11.5px', fontWeight: 800, color: '#34343c' }}>{idx + 1}번 플래그</span><span style={{ fontSize: '10.5px', color: '#9a9aa6' }}>텍스트/이미지</span></div>
                        <div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
                          {[{ id: 'text', label: '텍스트' }, { id: 'image', label: '이미지' }].map((m) => <label key={m.id} style={{ flex: 1, height: '30px', borderRadius: '8px', border: item.mode === m.id ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: item.mode === m.id ? '#eef2ff' : '#fff', color: item.mode === m.id ? '#4E4CDB' : '#6b6b78', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '5px', cursor: 'pointer', fontSize: '11.5px', fontWeight: 800 }}>
                            <input type="radio" name={'flag-mode-' + idx} checked={item.mode === m.id} onChange={() => updateFlagItem(idx, { mode: m.id })} style={{ margin: 0 }} />{m.label}
                          </label>)}
                        </div>
                        {item.mode === 'text' ?
                    <textarea value={item.text} onChange={(e) => updateFlagItem(idx, { text: e.target.value.split(String.fromCharCode(13)).join('').split(NL).slice(0, 2).join(NL) })} rows={2} placeholder={FLAG_DEFAULT_TEXT} style={{ width: '100%', minHeight: '54px', resize: 'vertical', boxSizing: 'border-box', padding: '8px 10px', borderRadius: '8px', border: '1px solid #e0e0e8', font: 'inherit', fontSize: '12px', lineHeight: 1.35, color: '#34343c' }} /> :
                    <div>
                            <button onClick={() => flagImgRefs[idx].current && flagImgRefs[idx].current.click()} {...dz((files) => setFlagImg(idx, files))} style={{ width: '100%', minHeight: '42px', borderRadius: '8px', border: '1.5px dashed #cfcfda', background: '#fafafb', cursor: 'pointer', font: 'inherit', fontSize: '12px', fontWeight: 800, color: '#4E4CDB', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}>{item.src ? '이미지 변경' : '이미지 업로드'}</button>
                            {item.src && <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}><span style={{ width: '42px', height: '42px', borderRadius: '50%', background: '#f4f4f8', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.06)' }}><img src={item.src} alt="" style={{ width: '72%', height: '72%', objectFit: 'contain' }} /></span><button onClick={() => clearFlagImg(idx)} style={{ border: 'none', background: 'none', padding: 0, cursor: 'pointer', color: '#d33', font: 'inherit', fontSize: '11.5px', fontWeight: 800 }}>제거</button></div>}
                          </div>}
                      </div>)}
                    </div>
                    <p style={{ fontSize: '11px', color: '#9a9aa6', marginTop: '8px' }}>로고 아래(없으면 로고 위치) 우측 상단에 세로로 배치 · 사이 10px</p>
                  </div>}
                {exSel === 'mark' && <div style={{ marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '6px' }}>{MARK_TYPES.map((m) => <button key={m.id} onClick={() => setMarkType(markType === m.id ? null : m.id)} style={{ textAlign: 'left', padding: '8px 11px', borderRadius: '8px', font: 'inherit', fontSize: '12px', fontWeight: 700, cursor: 'pointer', border: markType === m.id ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: markType === m.id ? '#eef2ff' : '#fff', color: markType === m.id ? '#4E4CDB' : '#43434e' }}>{m.label}</button>)}</div>}
                  </div>}</React.Fragment>;})}
                </div>
              </section>}
            </>}
        </aside>
      </div>
      {editFaceSrc && <FaceMarkEditor src={editFaceSrc} initial={headChinData[editFaceSrc]} onSave={(h, c) => saveFaceMark(editFaceSrc, h, c)} onClose={() => setEditFaceSrc(null)} />}
      {saveOpen && window.BannerSavePopup && React.createElement(window.BannerSavePopup, { account: window.__bannerlyUser || '김배너', category: '상품배너 550', type: (BANNER_TYPES.find((t) => t.id === bannerType) || {}).label || '', fileNameRule: window.SCHEMA_550 && window.SCHEMA_550.fileNameRule, getValidation: getGuideValidation, getSources: collectSources, getProject: () => collectProject(true), getDraftId: () => draftIdRef.current, onSaved: (id, r) => { draftIdRef.current = id; savedRef.current = true; window.BannerEditorPersist.markSourcesSigIfStored(srcsSigRef, window.BannerEditorPersist.srcsSig(srcs), r, srcs.length); }, onClose: () => setSaveOpen(false), onConfirm: (v) => saveBanner({ ...v, type: (BANNER_TYPES.find((t) => t.id === bannerType) || {}).label || '' }), getBlob: () => layoutType === 'D' ? renderFullBlob(fullPrimary, fullFocalOf(fullPrimary)) : layoutType === 'C' ? renderSplitBlob(splitPrimary) : layoutType === 'B' ? renderBBlob(bPrimary, bPhoto, bFocalOf()) : layoutType === 'E' ? renderEBlob() : renderEditCandBlob() })}
      {toast && <div className="ap-toast" role="status" aria-live="polite">{toast}</div>}
    </div>);

}

/* 저장 팝업은 공용 컴포넌트로 이동: window.BannerSavePopup (nukki-shared/save-popup.jsx) */

function PlusBadge() {
  return (
    <span style={{ position: 'relative', width: '22px', height: '22px', borderRadius: '50%', background: '#38006A', flex: '0 0 auto', display: 'inline-block' }}>
      <span style={{ position: 'absolute', left: '10px', top: '5px', width: '2px', height: '12px', background: '#fff', borderRadius: '1px' }} />
      <span style={{ position: 'absolute', left: '5px', top: '10px', width: '12px', height: '2px', background: '#fff', borderRadius: '1px' }} />
    </span>);

}
function AddImageBadge({ type, title, src, onPick, onClear, onDropFiles, kind, pos, onPosChange }) {
  const t = ADD_IMG_TYPES.find((x) => x.id === type) || ADD_IMG_TYPES[0];
  const heading = title != null ? String(title).trim() || t.label : t.label;
  const p = { x: clampPct(pos && pos.x != null ? pos.x : 50), y: clampPct(pos && pos.y != null ? pos.y : 50), zoom: clampAddImgZoom(pos && pos.zoom != null ? pos.zoom : 1) };
  const renderZoom = p.zoom;
  const panRef = React.useRef(null);
  const startPan = (e) => {
    if (!src || !onPosChange || (kind !== 'photo' && p.zoom <= 1.001)) return;
    e.preventDefault();e.stopPropagation();
    const rect = e.currentTarget.getBoundingClientRect();
    panRef.current = { sx: e.clientX, sy: e.clientY, x: p.x, y: p.y, w: rect.width, h: rect.height };
    const mv = (ev) => {const d = panRef.current;if (!d) return;onPosChange({ ...p, x: clampPct(d.x - (ev.clientX - d.sx) / d.w * 100), y: clampPct(d.y - (ev.clientY - d.sy) / d.h * 100) });};
    const up = () => {panRef.current = null;window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const setZoom = (v) => {if (onPosChange) onPosChange({ ...p, zoom: +clampAddImgZoom(v).toFixed(2) });};
  const onWheel = (e) => {
    if (!src || !onPosChange) return;
    e.preventDefault();e.stopPropagation();
    setZoom(p.zoom + (e.deltaY < 0 ? 0.05 : -0.05));
  };
  const imgStyle = kind === 'nukki'
    ? { width: '100%', height: '100%', objectFit: 'contain', padding: '12%', boxSizing: 'border-box', pointerEvents: 'none', transform: `scale(${renderZoom})`, transformOrigin: p.x + '% ' + p.y + '%' }
    : { width: '100%', height: '100%', objectFit: 'contain', objectPosition: p.x + '% ' + p.y + '%', pointerEvents: 'none', transform: `scale(${renderZoom})`, transformOrigin: p.x + '% ' + p.y + '%' };
  return (
    <div style={{ width: '190px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '9px' }}>
      <div style={{ width: '130px', height: '130px', borderRadius: '15px', background: '#fff', overflow: 'hidden', position: 'relative', boxShadow: '0 1px 3px rgba(0,0,0,.12), 0 0 0 1px #ececf2' }}>
        <div style={{ position: 'absolute', left: 0, top: 0, width: '130px', height: '40px', borderRadius: '15px 15px 0 0', background: '#680ABA', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
          {t.icon && <PlusBadge />}
          <span style={{ color: '#fff', fontSize: '20px', lineHeight: 1, letterSpacing: '-0.02em', fontWeight: 500 }}>{heading}</span>
        </div>
        <div onClick={src ? undefined : onPick} onWheel={onWheel} onPointerDown={startPan} onDragOver={(e) => {e.preventDefault();e.stopPropagation();e.currentTarget.classList.add('drop-over');}} onDragLeave={(e) => e.currentTarget.classList.remove('drop-over')} onDrop={(e) => {e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-over');onDropFiles && onDropFiles(e.dataTransfer.files);}} style={{ position: 'absolute', left: 0, top: '40px', width: '130px', height: '90px', background: src ? '#fafafb' : '#f4f4f8', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: src ? kind === 'photo' || p.zoom > 1.001 ? 'grab' : 'default' : 'pointer', touchAction: 'none' }}>
          {src ? <img src={src} alt="" draggable={false} style={imgStyle} /> : <span style={{ color: '#b5b5c0', fontSize: '24px' }}>+</span>}
          {src && (kind === 'photo' || p.zoom > 1.001) && <span style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%,-50%)', width: '26px', height: '26px', borderRadius: '7px', background: 'rgba(28,28,34,.55)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20" /></svg></span>}
        </div>
        {src && <button onClick={onClear} style={{ position: 'absolute', right: '6px', top: '46px', width: '18px', height: '18px', borderRadius: '5px', border: 'none', background: 'rgba(28,28,34,.78)', color: '#fff', cursor: 'pointer', fontSize: '11px', lineHeight: 1 }}>×</button>}
      </div>
      {src && <div style={{ width: '190px', display: 'grid', gridTemplateColumns: '24px 1fr 24px', alignItems: 'center', gap: '8px' }}>
        <button type="button" title="축소" onClick={() => setZoom(p.zoom - 0.1)} style={{ width: '24px', height: '24px', borderRadius: '7px', border: '1px solid #e0e0e8', background: '#fff', color: '#4E4CDB', cursor: 'pointer', fontSize: '16px', fontWeight: 800, lineHeight: 1 }}>-</button>
        <div style={{ minWidth: 0 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '4px' }}><span style={{ fontSize: '11px', fontWeight: 800, color: '#43434e' }}>확대/축소</span><span style={{ fontSize: '11px', fontWeight: 800, color: '#4E4CDB' }}>{Math.round(p.zoom * 100)}%</span></div>
          <input type="range" min="0.5" max="3" step="0.05" value={p.zoom} onChange={(e) => setZoom(e.target.value)} style={{ width: '100%', display: 'block', accentColor: '#4E4CDB' }} />
        </div>
        <button type="button" title="확대" onClick={() => setZoom(p.zoom + 0.1)} style={{ width: '24px', height: '24px', borderRadius: '7px', border: '1px solid #e0e0e8', background: '#fff', color: '#4E4CDB', cursor: 'pointer', fontSize: '16px', fontWeight: 800, lineHeight: 1 }}>+</button>
      </div>}
      {src && p.zoom !== 1 && <button type="button" onClick={() => setZoom(1)} style={{ border: 'none', background: 'none', padding: 0, cursor: 'pointer', color: '#8a8a96', fontSize: '11px', fontWeight: 800 }}>100%로 재설정</button>}
    </div>);

}
function DecoBadge({ deco }) {
  if (!deco) return null;
  if (deco.kind === 'text') return <AddTextBadge text={deco.text} size={90} />;
  if (deco.kind === 'image') {
    const t = ADD_IMG_TYPES.find((x) => x.id === deco.type) || ADD_IMG_TYPES[0];
    const heading = deco.title != null ? String(deco.title).trim() || t.label : t.label;
    const p = { x: clampPct(deco.pos && deco.pos.x != null ? deco.pos.x : 50), y: clampPct(deco.pos && deco.pos.y != null ? deco.pos.y : 50), zoom: clampAddImgZoom(deco.pos && deco.pos.zoom != null ? deco.pos.zoom : 1) };
    const renderZoom = p.zoom;
    const imgStyle = deco.imgKind === 'nukki'
      ? { width: '100%', height: '100%', objectFit: 'contain', padding: '12%', boxSizing: 'border-box', transform: `scale(${renderZoom})`, transformOrigin: p.x + '% ' + p.y + '%' }
      : { width: '100%', height: '100%', objectFit: 'contain', objectPosition: p.x + '% ' + p.y + '%', transform: `scale(${renderZoom})`, transformOrigin: p.x + '% ' + p.y + '%' };
    return (
      <div style={{ width: '130px', height: '130px', borderRadius: '15px', background: '#fff', overflow: 'hidden', position: 'relative', boxShadow: '13px 15px 50px 0px rgba(202,202,202,0.5)' }}>
        <div style={{ position: 'absolute', left: 0, top: 0, width: '130px', height: '40px', borderRadius: '15px 15px 0 0', background: '#680ABA', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
          {t.icon && <span style={{ position: 'relative', width: '22px', height: '22px', borderRadius: '50%', background: '#38006A', flex: '0 0 auto', display: 'inline-block' }}><span style={{ position: 'absolute', left: '10px', top: '5px', width: '2px', height: '12px', background: '#fff', borderRadius: '1px' }} /><span style={{ position: 'absolute', left: '5px', top: '10px', width: '12px', height: '2px', background: '#fff', borderRadius: '1px' }} /></span>}
          <span style={{ color: '#fff', fontSize: '20px', fontWeight: 600, letterSpacing: '-0.02em' }}>{heading}</span>
        </div>
        <div style={{ position: 'absolute', left: 0, top: '40px', width: '130px', height: '90px', background: '#fafafb', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{deco.src ? <img src={deco.src} alt="" style={imgStyle} /> : null}</div>
      </div>);
  }
  if (deco.kind === 'flag') {
    const items = normalizeFlagItems(deco.items).slice(0, deco.count || 1);
    return <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', alignItems: 'flex-end' }}>{items.map((item, i) => {
      if (item.mode === 'image' && item.src) {
        return <div key={i} style={{ width: '90px', height: '90px', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'visible' }}><img src={item.src} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /></div>;
      }
      const lines = flagTextLines(item.text);
      const maxLen = Math.max.apply(null, lines.map((line) => line.length).concat(1));
      const fs = Math.max(11, Math.min(lines.length > 1 ? 17 : 22, 72 / (maxLen * 0.62)));
      return <div key={i} style={{ width: '90px', height: '90px', borderRadius: '50%', background: 'rgba(104,10,186,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 10px rgba(104,10,186,.22)', overflow: 'hidden' }}><span style={{ color: '#fff', fontSize: fs + 'px', fontWeight: 700, lineHeight: 1.12, textAlign: 'center', transform: 'translateY(-0.055em)', whiteSpace: 'pre-line', maxWidth: '76px', wordBreak: 'keep-all' }}>{lines.join(NL)}</span></div>;
    })}</div>;
  }
  if (deco.kind === 'mark') {
    const label = deco.type === '다수구성' ? '+' : deco.type === 'OR' ? 'OR' : '구성';
    const w = deco.type === '다수구성' ? 30 : 38;
    return <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>{Array.from({ length: deco.count || 1 }).map((_, i) => <span key={i} style={{ width: w + 'px', height: '30px', borderRadius: '999px', background: '#4E4CDB', color: '#fff', fontSize: label.length > 2 ? '11px' : '13px', fontWeight: 800, lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 8px rgba(78,76,219,.24)' }}>{label}</span>)}</div>;
  }
  if (deco.kind === 'color') {
    const overflow = deco.colors.length > 5;
    const cols = overflow ? deco.colors.slice(0, 4) : deco.colors;
    return <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'center' }}>{cols.map((c, i) => <span key={i} style={{ width: '20px', height: '27px', borderRadius: '100px', background: c }} />)}{overflow && <span style={{ width: '20px', height: '27px', borderRadius: '100px', background: '#c6c6c6', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: '13px', fontWeight: 800 }}>+</span>}</div>;
  }
  return null;
}
window.DecoBadge = DecoBadge;
function FaceMarkEditor({ src, initial, onSave, onClose }) {
  const W = 340;
  const wrapRef = React.useRef(null);
  const [img, setImg] = React.useState(null);
  const [head, setHead] = React.useState(initial && initial.head != null ? initial.head : 0.08);
  const [chin, setChin] = React.useState(initial && initial.chin != null ? initial.chin : 0.22);
  React.useEffect(() => {const im = new Image();im.onload = () => setImg(im);im.src = src;}, [src]);
  const H = img ? Math.round(W * img.height / Math.max(1, img.width)) : 300;
  const drag = (which) => (e) => {
    e.preventDefault();
    const rect = wrapRef.current.getBoundingClientRect();
    const mv = (ev) => {const f = Math.max(0, Math.min(1, (ev.clientY - rect.top) / rect.height));which === 'head' ? setHead(f) : setChin(f);};
    const up = () => {window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const lines = [['head', head, '#E0322E', '머리끝'], ['chin', chin, '#2A6FDB', '턱끝']];
  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(20,20,28,.55)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onPointerDown={(e) => {if (e.target === e.currentTarget) onClose();}}>
      <div style={{ background: '#fff', borderRadius: '14px', padding: '18px', width: W + 36 + 'px', boxShadow: '0 20px 60px rgba(0,0,0,.3)' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}><b style={{ fontSize: '14px', color: '#1c1c22' }}>기준선 지정</b><button onClick={onClose} style={{ border: 'none', background: 'none', cursor: 'pointer', color: '#8a8a96', fontSize: '20px', lineHeight: 1 }}>×</button></div>
        <p style={{ fontSize: '11.5px', color: '#8a8a96', margin: '0 0 12px' }}>두 라인을 드래그해 머리끝·턱끝에 맞추세요. 이 값으로 모델끼리 정렬됩니다.</p>
        <div ref={wrapRef} style={{ position: 'relative', width: W + 'px', height: H + 'px', margin: '0 auto', background: '#f4f4f8', borderRadius: '8px', overflow: 'hidden', userSelect: 'none', touchAction: 'none' }}>
          {img && <img src={src} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />}
          {lines.map(([k, v, c, lbl]) => <div key={k} onPointerDown={drag(k)} style={{ position: 'absolute', left: 0, right: 0, top: v * H + 'px', height: '16px', marginTop: '-8px', cursor: 'ns-resize' }}><div style={{ position: 'absolute', left: 0, right: 0, top: '7px', height: '2px', background: c }} /><span style={{ position: 'absolute', left: '4px', top: '-1px', fontSize: '10px', fontWeight: 800, color: '#fff', background: c, padding: '1px 6px', borderRadius: '4px' }}>{lbl}</span></div>)}
        </div>
        <div style={{ display: 'flex', gap: '8px', marginTop: '14px' }}>
          <button onClick={onClose} style={{ flex: 1, padding: '10px', borderRadius: '9px', border: '1px solid #e7e7ee', background: '#fff', cursor: 'pointer', font: 'inherit', fontWeight: 700, color: '#6b6b78' }}>취소</button>
          <button onClick={() => onSave(Math.min(head, chin), Math.max(head, chin))} style={{ flex: 1, padding: '10px', borderRadius: '9px', border: 'none', background: '#4E4CDB', color: '#fff', cursor: 'pointer', font: 'inherit', fontWeight: 700 }}>저장</button>
        </div>
      </div>
    </div>);

}
function FashionResult({ cand, rects, cands, activeId, onPick, logos, deco, decoPos, onDecoPosChange, onDecoScale, decoSelected, onSelectDeco, selectedId, onSelect, onChange, onReset, onApplyGuide, onDownload, onBegin, onUndo, canUndo, onRedo, canRedo, onDeleteItem, guideOn = true, selLogoIdx, onSelectLogo, onLogoScale, imageAdjust, imageAdjustBySource, onSelectionChange }) {
  return (
    <>
      <div className="ap-results">
        <div className="ap-card active">
          <div className="ap-card-head"><span className="ap-rank r1">패션</span><span className="ap-card-titles"><span className="ap-card-name">{cand.strategyLabel}</span><span className="ap-card-desc">{cand.rowsLabel} · 세이프 가득 · 하반신 크롭</span></span></div>
          <APStage rects={rects} dispW={460} editable selectedId={selectedId} onSelect={onSelect} onChange={onChange} bounds={cand.bounds} onBegin={onBegin} noReflow logos={logos} deco={deco} decoPos={decoPos} onDecoPosChange={onDecoPosChange} onDecoScale={onDecoScale} decoSelected={decoSelected} onSelectDeco={onSelectDeco} onDeleteItem={onDeleteItem} guideOn={guideOn} selLogoIdx={selLogoIdx} onSelectLogo={onSelectLogo} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} onSelectionChange={onSelectionChange} />
          <LogoSizeSlider logos={logos} selIdx={selLogoIdx} onScale={onLogoScale} />
          <window.APMetFold>
            <APMetricRow k="모델 수" v={cand.metrics.models + '인'} strong />
            <APMetricRow k="머리 상단" v="세이프 상단 정렬" tone="good" />
            <APMetricRow k="가장자리 얼굴" v="세이프 좌·우 안" tone="good" />
            <APMetricRow k="하단" v="하반신 자연 크롭" />
          </window.APMetFold>
          <div className="ap-card-actions">
            <button className="ap-btn icon" title="되돌리기 (⌘Z)" onClick={onUndo} disabled={!canUndo}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M9 14L4 9l5-5" /><path d="M4 9h11a5 5 0 0 1 0 10h-1" /></svg></button>
            <button className="ap-btn icon" title="다시 실행 (⇧⌘Z)" onClick={onRedo} disabled={!canRedo}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M15 14l5-5-5-5" /><path d="M20 9H9a5 5 0 0 0 0 10h1" /></svg></button>
            <button className="ap-btn ghost" onClick={onReset}>배치 초기화</button>
            {onApplyGuide && <button className="ap-btn ghost" onClick={onApplyGuide}>규칙 적용</button>}
          </div>
        </div>
      </div>
      {cands && cands.length > 1 && <div className="ap-rankstrip"><div className="ap-rankstrip-head"><b>배열 후보</b><span>{cands.length}개 · 순서순</span></div><div className="ap-rankstrip-row">{cands.map((c, i) => <APMiniCanvas key={c.id} cand={c} rank={i + 1} active={c.id === activeId} onClick={() => onPick(c.id)} />)}</div></div>}
      <p className="ap-hint">모델을 클릭해 선택하고 <b>드래그로 이동 · 모서리 핸들로 크기</b>를 조정하세요. 방향키(⇧=10px) 이동, ⌘Z 되돌리기도 됩니다. 좌·우 끝 모델의 얼굴은 세이프 안, 하단은 캔버스 끝으로 자연 크롭됩니다.</p>
    </>);

}
function DecorSchema({ logoOn, exSel, addText, markType, addImgType, chipColors }) {
  return (
    <div style={{ marginTop: '14px' }}>
      <div style={{ fontSize: '11px', fontWeight: 700, color: '#9a9aa6', marginBottom: '6px' }}>배치 미리보기</div>
      <div style={{ position: 'relative', width: '100%', aspectRatio: '1/1', background: '#f1f1f5', borderRadius: '10px', overflow: 'hidden' }}>
        <div style={{ position: 'absolute', inset: '11%', border: '1.5px dashed rgba(78,76,219,.35)', borderRadius: '4px' }} />
        <div style={{ position: 'absolute', left: '16%', bottom: '16%', display: 'flex', gap: '5px' }}>
          {[0, 1, 2].map((i) => <div key={i} style={{ position: 'relative', width: '34px', height: '54px', background: '#d8d8e2', borderRadius: '3px' }}>{exSel === 'mark' && markType && i < 2 && <span style={{ position: 'absolute', right: '-9px', top: '50%', transform: 'translateY(-50%)', zIndex: 2, width: '16px', height: '16px', borderRadius: '50%', background: '#4E4CDB', color: '#fff', fontSize: '8px', fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{markType === '다수구성' ? '+' : markType === 'OR' ? 'OR' : '◦'}</span>}</div>)}
        </div>
        {(logoOn || exSel) && <div style={{ position: 'absolute', right: '11%', top: '13%', width: '38%', height: '14px', background: logoOn ? '#4E4CDB' : '#c3c3d0', borderRadius: '3px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: '8px', fontWeight: 800 }}>LOGO</div>}
        {exSel && exSel !== 'mark' && <div style={{ position: 'absolute', right: '11%', top: 'calc(13% + 22px)', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
          {exSel === 'text' && <AddTextBadge text={addText || 'SET'} size={34} />}
          {exSel === 'image' && <span style={{ width: '42px', borderRadius: '6px', overflow: 'hidden', display: 'inline-block', boxShadow: '0 0 0 1px rgba(0,0,0,.08)' }}><span style={{ display: 'block', background: '#680ABA', color: '#fff', fontSize: '6px', fontWeight: 800, textAlign: 'center', padding: '3px 0' }}>{addImgType}</span><span style={{ display: 'block', height: '24px', background: '#fff' }} /></span>}
          {exSel === 'color' && <div style={{ display: 'flex', flexDirection: 'column', gap: '3px' }}>{(chipColors && chipColors.length ? chipColors : ['#680ABA', '#38006A', '#c6c6c6']).slice(0, 5).map((c, i) => <span key={i} style={{ width: '13px', height: '18px', borderRadius: '100px', background: c }} />)}</div>}
          {exSel === 'flag' && [0, 1].map((i) => <span key={i} style={{ width: '24px', height: '24px', borderRadius: '50%', background: '#4E4CDB', color: '#fff', fontSize: '6px', fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>인증</span>)}
        </div>}
      </div>
    </div>);

}
/* 우측 상단 로고/부가정보 오버레이 els (550 좌표계) — canvas.jsx decoEls와 동일 규칙.
   가로 로고 h20 / 세로 h60, 우측여백 50, 로고 아래 30px 부가정보. deco 없으면 로고만. */
function apOverlayEls(logos, deco, decoPos, onDecoPointerDown, selLogoIdx, onSelectLogo, decoSelected, onDecoResizeDown) {
  const out = [];let logoBottom = 40;
  if (logos && logos.length) {
    const row = logoRow550(logos);
    const arr = row.items;
    const top = 40;
    for (let i = arr.length - 1; i >= 0; i--) {
      const w = arr[i].w, h = arr[i].h;
      // Top-right anchor: right edge fixed at precomputed rightEdge, grows left/down
      const x = arr[i].rightEdge - w, sel = selLogoIdx === i;
      out.push(<img key={'lg' + i} data-logo-idx={i} src={arr[i].url} alt="" draggable={false}
        onPointerDown={(e) => { if (!onSelectLogo) return; e.stopPropagation(); e.preventDefault(); onSelectLogo(i); }}
        style={{ position: 'absolute', left: x, top: top, width: w, height: h, objectFit: 'contain', pointerEvents: onSelectLogo ? 'auto' : 'none', cursor: onSelectLogo ? 'pointer' : 'default', outline: sel ? '1.5px solid #4E4CDB' : 'none', outlineOffset: 2 }} />);
    }
    logoBottom = top + row.rowH;
  }
  if (deco && window.DecoBadge) {
    const pos = decoPos && typeof decoPos === 'object' ? decoPos : null;
    const top = pos && pos.y != null ? Math.max(0, Math.min(550 - (deco.h || 90), Number(pos.y) || 0)) : (logos && logos.length ? logoBottom + 30 : 40);
    const left = pos && pos.x != null ? Math.max(0, Math.min(550 - (deco.w || 90), Number(pos.x) || 0)) : (550 - 50 - (deco.w || 90));
    const ds = Number(deco.scale) || 1;
    const bw = deco.baseW || (deco.w || 90) / ds;
    const bh = deco.baseH || (deco.h || 90) / ds;
    out.push(<div key="deco" data-deco-overlay="550" onPointerDown={onDecoPointerDown} style={{ position: 'absolute', left, top, width: deco.w || bw * ds, height: deco.h || bh * ds, pointerEvents: onDecoPointerDown ? 'auto' : 'none', cursor: onDecoPointerDown ? 'move' : 'default', touchAction: 'none', userSelect: 'none', outline: decoSelected ? '1.5px solid #4E4CDB' : 'none', outlineOffset: 3 }}>
      <div style={{ width: bw, height: bh, transform: `scale(${ds})`, transformOrigin: 'top left' }}>{React.createElement(window.DecoBadge, { deco })}</div>
      {decoSelected && onDecoResizeDown && <span data-deco-resize="550" title="부가정보 크기 조절" onPointerDown={onDecoResizeDown} style={{ position: 'absolute', right: -8, bottom: -8, width: 15, height: 15, border: '2px solid #fff', borderRadius: 2, background: '#4E4CDB', cursor: 'nwse-resize', boxShadow: '0 1px 5px rgba(0,0,0,.18)' }} />}
    </div>);
  }
  return out;
}
// 여백 가이드 오버레이 (B/C/D/E 스테이지 공용 — 550 좌표계, pointer-events: none)
function SafeGuideOverlay({ sx, sy, cw, ch }) {
  const band = { position: 'absolute', background: 'rgba(0,0,0,0.14)', pointerEvents: 'none' };
  const lbl = { position: 'absolute', fontSize: 10.5, fontWeight: 700, fontFamily: 'monospace', color: 'rgba(78,76,219,0.85)', pointerEvents: 'none' };
  const safeW = cw - sx * 2, safeH = ch - sy * 2;
  return (
    <>
      <div style={{ ...band, left: 0, top: 0, width: cw, height: sy }} />
      <div style={{ ...band, left: 0, top: ch - sy, width: cw, height: sy }} />
      <div style={{ ...band, left: 0, top: sy, width: sx, height: safeH }} />
      <div style={{ ...band, left: cw - sx, top: sy, width: sx, height: safeH }} />
      <div style={{ position: 'absolute', left: sx, top: sy, width: safeW, height: safeH, outline: '1px solid rgba(78,76,219,0.55)', pointerEvents: 'none' }} />
      <span style={{ ...lbl, right: Math.floor(sx / 2) - 8, top: Math.floor(ch / 2) - 7 }}>{sx}</span>
      <span style={{ ...lbl, left: Math.floor(cw / 2) - 8, top: Math.floor(sy / 2) - 7 }}>{sy}</span>
    </>
  );
}
/* E 보험 레이아웃(550 좌표계) — 로고+보험명 좌상단, 일러스트 중앙-하단, 모델(선택) 우측 밴드, 부가정보 좌하단.
   미리보기(EStage)와 다운로드(renderEBlob)가 같은 좌표를 쓰도록 단일 출처. */
// E 보험 템플릿 위치·크기 상수 (한 곳). 여기 값만 바꾸면 미리보기·다운로드 동시 반영.
const E_CFG = {
  W: 550, H: 550, safeX: 40, safeY: 40,          // 캔버스·세이프(보험 전용: 좌우 40·상하 40)
  logoH_h: 50, logoH_v: 64, logoGap: 10,         // [550 550×550] 로고 높이: 가로형 50 / 세로형 64
  nameFont: 36,                                   // [550] 보험명 폰트(2줄이 흔함) — 레퍼런스 실측
  logoTop: 50,                                    // [550] 배너 상단 → 로고 (절대 px, 레퍼런스 34px→×1.46)
  nameGap: 16,                                    // [550] 로고 아래 → 보험명 시작 간격 (절대 px, 레퍼런스 11px→×1.46)
  nameToModel: 12, nameToIllust: 78,             // 보험명 우측↔모델 간격 / 보험명 아래→일러스트 여유
  modelMaxWRatio: 0.46, modelAspect: 0.62,       // (구)모델 밴드 최대 폭 비율 / 기본 비율
  // [550 보험 인물누끼] 우측 상체형 — 우측 여백 · 모델 왼쪽부터는 인물 영역, 정수리=상단 세이프.
  //   일러스트/로고/보험명은 모델 좌측 영역 안에서 다시 중앙 정렬해 인물 뒤로 밀리지 않게 한다.
  modelAnchor: { mode: 'fashion', rightMarginFrac: 0.1, leftLimitFrac: 0.58 },
  illustTopGap: 16,                              // 보험명 아래 → 일러스트 시작 간격(하단앵커 contain)
  bgDefault: '#F0F5EE',                          // 보험 기본 배경색(레퍼런스 실측) — 컬러칩/커스텀으로 override
  decoDefault: 90, illustBotGap: 12, illustMinW: 40, illustMinH: 60 };
function eLayout(opts) {
  const C = E_CFG;
  const S = { x: C.safeX, y: C.safeY, r: C.W - C.safeX, b: C.H - C.safeY };
  const logoMeta = opts.logoMeta || [];
  const hasModel = !!opts.hasModel;
  // [보험 규칙] 비례(캔버스 H 대비): 로고 높이 H×0.12·상단 H×0.09, 보험명 폰트 H×0.09·로고아래 H×0.05. 모델없음=가로중앙, 모델=좌측.
  const logoTop = C.logoTop; // [보험] 배너별 고정 상단여백(E_CFG, 절대 px)
  const logoHh = C.logoH_h, logoHv = C.logoH_v; // [보험] 배너별 고정 로고 높이(E_CFG) — 가로형/세로형
  const nameFont = C.nameFont, nameGapV = C.nameGap; // [보험] 배너별 고정 보험명 폰트·간격(E_CFG, 절대 px)
  let model = null;
  if (hasModel) {
    const asp = opts.modelAspect || C.modelAspect;const A = C.modelAnchor || {};
    const rightMargin = C.safeX * (A.rightMarginFrac != null ? A.rightMarginFrac : 0.1);
    const mx = C.W * (A.leftLimitFrac != null ? A.leftLimitFrac : 0.58);
    const w = Math.max(10, (C.W - rightMargin) - mx);const h = w / asp;
    model = { x: mx, y: C.safeY, w, h, fit: 'nukki' };
  }
  const regL = S.x, regR = hasModel && model ? model.x : S.r, regC = (regL + regR) / 2;
  const logos = [];let logoRowH = 0;
  if (logoMeta.length) {
    const row = logoRow550(logoMeta, (g) => {const aspect = logoAspect550(g.aspect);return (g.orient === 'h' || aspect >= 1.6) ? logoHh : logoHv;});
    let arr = row.items.map((a) => ({ ...a }));
    logoRowH = row.rowH;
    let rowW = row.rowW;
    const maxInsuranceLogoW = Math.max(10, regR - regL);
    if (!hasModel && rowW > maxInsuranceLogoW) {
      const kFit = maxInsuranceLogoW / rowW;
      arr = arr.map((a) => ({ ...a, w: a.w * kFit, h: a.h * kFit }));
      rowW *= kFit;
      logoRowH *= kFit;
    }
    if (hasModel && rowW > maxInsuranceLogoW) {
      const kFit = maxInsuranceLogoW / rowW;
      arr = arr.map((a) => ({ ...a, w: a.w * kFit, h: a.h * kFit }));
      rowW *= kFit;
      logoRowH *= kFit;
    }
    let x = regC - rowW / 2;
    arr.forEach((a) => {logos.push({ url: a.url, x, y: logoTop, w: a.w, h: a.h });x += a.w + row.gap;});
    if (hasModel && logos.length) {
      const last = logos[logos.length - 1];
      const right = last.x + last.w;
      if (right > LOGO_550_MID + 0.0001) {
        const origin = logos[0].x;
        const span = right - origin;
        const kFit = span > 0 ? (LOGO_550_MID - origin) / span : 1;
        if (kFit < 1) {
          let nx = origin;
          logos.forEach((lg, i) => { lg.w *= kFit; lg.h *= kFit; lg.x = nx; nx += lg.w + row.gap * kFit; });
          logoRowH *= kFit;
        }
      }
    }
  }
  const nameY = logoTop + (logoRowH ? logoRowH + nameGapV : 0);
  let nameRight = regR;
  let decoBox = null;
  if (opts.deco) {const dw = opts.deco.w || C.decoDefault, dh = opts.deco.h || C.decoDefault;decoBox = { x: S.x, y: S.b - dh, w: dw, h: dh };}
  // 보험명 텍스트 박스 하단 y (일러스트 배치 상한 계산용). nameLines=보험명 줄 수(opts).
  const nameLines = opts.nameLines || 0;
  const textBottom = nameLines > 0 ? nameY + Math.round(nameLines * nameFont * 1.2) : (logoRowH ? logoTop + logoRowH : S.y);
  // [보험 배경] 일러스트는 "보험명 텍스트 박스 아래 ~ 하단 세이프" 영역에 좌우 세이프 전폭으로 하단앵커 contain.
  //   → 보험명 텍스트 박스만 안 침범(위쪽만 피함). 인물 누끼 영역은 침범 허용(인물이 최전면으로 덮음). 배경은 이미지 배경색.
  let illust = null;
  if (opts.illustAspect) {
    const illTop = textBottom + (C.illustTopGap != null ? C.illustTopGap : 16);
    illust = { x: regL, y: illTop, w: Math.max(10, regR - regL), h: Math.max(10, S.b - illTop), contain: true, anchorBottom: true, aspect: opts.illustAspect };
  }
  return { logos, nameX: regL, nameY, nameRight, nameCenter: true, nameFont, model, illust, decoBox };
}
function eScaledBox(box, zoom, anchor) {
  if (!box) return null;
  const z = Math.max(0.5, Math.min(2.5, Number(zoom) || 1));
  const w = box.w * z, h = box.h * z;
  const x = anchor === 'right-bottom' ? box.x + box.w - w : box.x + (box.w - w) / 2;
  const y = box.y + box.h - h;
  return { ...box, x, y, w, h };
}
/* D 풀이미지형 스테이지 — 화보를 550×550에 cover-fit. 넘치는 축으로 드래그해 초점 조정.
   공유 canvas.jsx(APStage)는 contain 전용이라 D 전용 cover 렌더를 이 파일 안에 둔다(공유 파일 무수정). */
function FullStage({ url, source, overflow, focal, onFocal, onBegin, dispW = 460, logos, deco, decoPos, onDecoPosChange, onDecoScale, decoSelected, onSelectDeco, onDeleteImage, guideOn = true, selLogoIdx, onSelectLogo, imageAdjust, imageAdjustBySource }) {
  const scale = dispW / 550;
  const f = focal || { x: 50, y: 50, zoom: 1 };
  const zoom = f.zoom || 1;
  const panRef = React.useRef(null);
  const decoDrag = React.useRef(null);
  const [menu, setMenu] = React.useState(null);
  const [resizeMode, setResizeMode] = React.useState(false);
  React.useEffect(() => {
    if (!menu) return;
    const close = () => setMenu(null);
    window.addEventListener('pointerdown', close);
    return () => window.removeEventListener('pointerdown', close);
  }, [menu]);
  const clampDecoPos = (p, d) => ({ x: Math.max(0, Math.min(550 - (d.w || 90), Number(p && p.x) || 0)), y: Math.max(0, Math.min(550 - (d.h || 90), Number(p && p.y) || 0)) });
  const defaultDecoPos = (() => {
    if (!deco) return { x: 0, y: 0 };
    let logoBottom = 40;
    if (logos && logos.length) {
      logoBottom = 40 + logoRow550(logos).rowH;
    }
    return { x: 550 - 50 - (deco.w || 90), y: logos && logos.length ? logoBottom + 30 : 40 };
  })();
  const currentDecoPos = deco ? clampDecoPos(decoPos && typeof decoPos === 'object' ? decoPos : defaultDecoPos, deco) : null;
  const startPan = (e) => {
    if (e.button === 2) return;
    if (onFocal) setResizeMode(true);
    if (!onFocal) return;
    onBegin && onBegin();
    e.preventDefault();
    const rect = e.currentTarget.getBoundingClientRect();
    panRef.current = { sx: e.clientX, sy: e.clientY, x: f.x, y: f.y, w: rect.width, h: rect.height };
    const mv = (ev) => {const d = panRef.current;if (!d) return;const nx = zoom > 1 ? clampPct(d.x - (ev.clientX - d.sx) / d.w * 100) : overflow === 'x' ? clampPct(d.x - (ev.clientX - d.sx) / d.w * 100) : 50;const ny = zoom > 1 ? clampPct(d.y - (ev.clientY - d.sy) / d.h * 100) : overflow === 'y' ? clampPct(d.y - (ev.clientY - d.sy) / d.h * 100) : 50;onFocal({ x: nx, y: ny, zoom });};
    const up = () => {panRef.current = null;window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const setZoom = (nz) => {if (!onFocal) return;onFocal({ x: f.x, y: f.y, zoom: Math.max(1, Math.min(3, +(nz).toFixed(3))) });};
  const onWheel = (e) => {if (!onFocal) return;e.preventDefault();onBegin && onBegin();setZoom(zoom - e.deltaY * 0.0015);};
  const startResize = (e) => {
    if (e.button === 2) return;
    if (!onFocal) return;
    onBegin && onBegin();
    e.preventDefault();e.stopPropagation();
    const sz = zoom, sx = e.clientX, sy = e.clientY;
    const mv = (ev) => {const d = (ev.clientX - sx + (ev.clientY - sy)) / 300;setZoom(sz + d);};
    const up = () => {window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const startDecoDrag = (e) => {
    if (!deco || !onDecoPosChange || e.button === 2) return;
    e.preventDefault();e.stopPropagation();
    onSelectDeco && onSelectDeco();
    decoDrag.current = { sx: e.clientX, sy: e.clientY, orig: currentDecoPos || defaultDecoPos };
    const mv = (ev) => {const d = decoDrag.current;if (!d) return;onDecoPosChange(clampDecoPos({ x: d.orig.x + (ev.clientX - d.sx) / scale, y: d.orig.y + (ev.clientY - d.sy) / scale }, deco));};
    const up = () => {decoDrag.current = null;window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const startDecoResize = (e) => {
    if (!deco || !onDecoScale || e.button === 2) return;
    e.preventDefault();e.stopPropagation();
    onSelectDeco && onSelectDeco();
    const sx = e.clientX, sy = e.clientY, orig = Number(deco.scale) || 1;
    const mv = (ev) => onDecoScale(Math.max(0.5, Math.min(2.4, +(orig + ((ev.clientX - sx) + (ev.clientY - sy)) / 180).toFixed(3))));
    const up = () => {window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const openImageMenu = (e) => {
    e.preventDefault(); e.stopPropagation();
    setMenu({ x: e.clientX, y: e.clientY });
  };
  const closeMenu = () => setMenu(null);
  const resizeFromMenu = () => { setResizeMode(true); closeMenu(); };
  const deleteFromMenu = () => { if (onDeleteImage) onDeleteImage(); closeMenu(); };
  const overlayEls = apOverlayEls(logos, deco, decoPos, onDecoPosChange ? startDecoDrag : null, selLogoIdx, onSelectLogo, decoSelected, onDecoScale ? startDecoResize : null);
  const menuPortal = menu && document.body ? (() => {
    const w = 168, h = onDeleteImage ? 150 : 114;
    const left = Math.max(8, Math.min(window.innerWidth - w - 8, menu.x));
    const top = Math.max(8, Math.min(window.innerHeight - h - 8, menu.y));
    return ReactDOM.createPortal(
      <div className="ap-ctx" style={{ position: 'fixed', left, top, zIndex: 100000 }} onPointerDown={(e) => e.stopPropagation()}>
        <button className="ap-ctx-item" onPointerDown={closeMenu}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5M5 12l7-7 7 7" /></svg>앞으로
        </button>
        <button className="ap-ctx-item" onPointerDown={closeMenu}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12l7 7 7-7" /></svg>뒤로
        </button>
        <button className="ap-ctx-item" onPointerDown={resizeFromMenu}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 9V4h5" /><path d="M20 15v5h-5" /><path d="M4 4l6 6" /><path d="M20 20l-6-6" /></svg>사이즈 변경
        </button>
        {onDeleteImage && (
          <button className="ap-ctx-item" onPointerDown={deleteFromMenu}>
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /></svg>삭제
          </button>
        )}
      </div>,
      document.body
    );
  })() : null;
  return (
    <div className="ap-stage-wrap" onContextMenu={openImageMenu} onPointerDown={(e) => { if (onSelectLogo && !(e.target && e.target.getAttribute && e.target.getAttribute('data-logo-idx') != null)) onSelectLogo(null); }} style={{ width: dispW, height: dispW, position: 'relative', overflow: 'hidden', borderRadius: 0, background: '#fff' }}>
      <div data-photo-panel="full" onPointerDown={startPan} onContextMenu={openImageMenu} onWheel={onWheel} style={{ position: 'absolute', inset: 0, cursor: onFocal ? 'grab' : 'default', touchAction: 'none' }}>
        <img src={url} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: f.x + '% ' + f.y + '%', transform: `scale(${zoom})`, transformOrigin: f.x + '% ' + f.y + '%', pointerEvents: 'none', ...((window.BannerImageTools && window.BannerImageTools.imageStyle(window.BannerImageTools.forSource ? window.BannerImageTools.forSource(imageAdjust, imageAdjustBySource, source || url) : imageAdjust)) || {}) }} />
      </div>
      <div style={{ position: 'absolute', top: 0, left: 0, width: 550, height: 550, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none' }}>
        {guideOn && <SafeGuideOverlay sx={50} sy={40} cw={550} ch={550} />}
        {overlayEls}
      </div>
      {onFocal && <React.Fragment>
        {resizeMode && <div data-photo-selection-tool="full" style={{ position: 'absolute', inset: 0, border: '1.5px solid #4E4CDB', boxSizing: 'border-box', pointerEvents: 'none', zIndex: 60 }} />}
        {zoom > 1.001 && <span style={{ position: 'absolute', left: 6, bottom: 6, zIndex: 60, background: 'rgba(28,28,34,.6)', color: '#fff', fontSize: 10, fontWeight: 700, borderRadius: 5, padding: '1px 6px', pointerEvents: 'none' }}>{Math.round(zoom * 100)}%</span>}
        <div onPointerDown={startResize} title="모서리를 드래그해 크기 조절" style={{ position: 'absolute', right: 0, bottom: 0, width: 20, height: 20, cursor: 'nwse-resize', zIndex: 61, touchAction: 'none', background: 'linear-gradient(135deg, transparent 46%, #4E4CDB 46%, #4E4CDB 60%, transparent 60%, transparent 72%, #4E4CDB 72%, #4E4CDB 86%, transparent 86%)' }} />
      </React.Fragment>}
      {menuPortal}
    </div>);

}
/* B~E 공통 결과 틀 — A 누끼형 카드와 동일한 구조(1·2순위 카드 + 인식등급 + 지표표 + 버튼 + 후보 랭킹).
   유형별로 다른 건 p(설정표)로만. 공통은 여기 한 곳만 고치면 B~E 전부 반영. A는 독립(canvas.jsx).
   p = { title, subtitle, primary:{cand,name,desc,stage,recBadge,showMetrics}, secondary?, cands, activeId, onPick, thumb, showRanking, hint } */
const AP_TONE = { good: '#1E9E6A', ok: '#C98A00', warn: '#C0451F', bad: '#C0451F' };
function apCardMetrics(cand) {
  const m = cand && cand.metrics || {};
  const g = m.exposure != null ? window.AP550.recognitionGrade(m.exposure) : null;
  const rows = [
    { k: '실루엣 적합도', v: m.silhouette != null ? Math.round(m.silhouette * 100) + '%' : '—', tone: m.silhouette != null ? m.silhouette >= 0.85 ? 'good' : m.silhouette < 0.66 ? 'warn' : null : null, strong: true },
    { k: '유효 노출', v: m.exposure != null ? m.exposure + '%' : '—', tone: g ? g.tone : null },
    { k: '제품 가려짐', v: m.occlusion != null ? m.occlusion + '%' : '—' },
    { k: '여백률', v: m.whitespace != null ? m.whitespace + '%' : '—' },
    { k: '세이프 밀도', v: m.density != null ? m.density + '%' : '—' },
    { k: '배치 유형', v: cand.strategyLabel || '—' },
    { k: '행 구성', v: cand.rowsLabel || '—' },
    { k: '겹친 쌍 / 최대', v: m.pairs != null ? m.pairs + '쌍 · ' + m.maxPair + '%' : '—' }];
  return { grade: g, rows };
}
function TypedCard({ card, primary, onDownload }) {
  const cand = card.cand || {};
  const mm = apCardMetrics(cand);
  const rank = card.rankNum || (primary ? 1 : 2);
  return (
    <div className={'ap-card' + (primary ? ' active' : '')}>
      <div className="ap-card-head">
        <span className={'ap-rank r' + rank}>{card.rankLabel || rank + '순위'}</span>
        <span className="ap-card-titles"><span className="ap-card-name">{card.name}</span><span className="ap-card-desc">{card.desc}</span></span>
        {primary && card.recBadge && <span className="ap-rec-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>스코어러 추천</span>}
      </div>
      {card.stage}
      {card.logoCtrl}
      {card.showMetrics !== false && <window.APMetFold recline={mm.grade ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: '7px', color: AP_TONE[mm.grade.tone], fontSize: '13px', fontWeight: 700, letterSpacing: '-.01em' }}><span className="ap-dot" style={{ background: AP_TONE[mm.grade.tone] }} />{mm.grade.label} — 유효 노출 기준</span> : null}>{mm.rows.map((r, i) => React.createElement(window.APMetricRow, { key: i, k: r.k, v: r.v, tone: r.tone, strong: r.strong }))}</window.APMetFold>}
      <div className="ap-card-actions">{primary ? <>
        {card.extraActions}
        {card.edit && <button className="ap-btn icon" title="되돌리기" onClick={card.edit.onUndo} disabled={!card.edit.canUndo}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M9 14L4 9l5-5" /><path d="M4 9h11a5 5 0 0 1 0 10h-1" /></svg></button>}
        {card.edit && <button className="ap-btn icon" title="다시 실행" onClick={card.edit.onRedo} disabled={!card.edit.canRedo}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M15 14l5-5-5-5" /><path d="M20 9H9a5 5 0 0 0 0 10h1" /></svg></button>}
        {card.edit && <button className="ap-btn ghost" onClick={card.edit.onReset}>배치 초기화</button>}
        {card.edit && card.edit.onApplyGuide && <button className="ap-btn ghost" onClick={card.edit.onApplyGuide}>규칙 적용</button>}
      </> : <button className="ap-btn ghost block" onClick={card.onMakePrimary}>이 후보로 조정하기</button>}</div>
    </div>);

}
function TypedResult({ p, onDownload }) {
  const cands = p.cands || [];
  return (
    <>
      <div className="ap-results">
        <TypedCard card={p.primary} primary onDownload={onDownload} />
      </div>
      {p.showRanking && cands.length >= 1 && <div className="ap-rankstrip"><div className="ap-rankstrip-head"><b>후보 랭킹</b><span>{cands.length}개 · 점수순</span></div><div className="ap-rankstrip-row">{cands.map((c, i) => {const active = c.id === p.activeId;const score = c.scoring ? c.scoring.score : 0;return <div key={c.id} className={'ap-mini' + (active ? ' active' : '') + (i < 2 ? ' top2' : '')}><button className="ap-mini-btn" onClick={() => p.onPick(c.id)} title={c.rowsLabel}><div className="ap-mini-canvas" style={{ position: 'relative' }}>{p.thumb(c, i)}</div><div className="ap-mini-meta"><span className="ap-mini-rank">{i + 1}</span><span className="ap-mini-score">{score.toFixed(2)}</span></div><div className="ap-mini-bar"><i style={{ width: Math.max(4, Math.min(100, score * 70 + 30)) + '%' }} /></div></button></div>;})}</div></div>}
      {p.hint && <p className="ap-hint">{p.hint}</p>}
    </>);

}
/* [SPLIT-FIX] C 분할형 스테이지 — 규격 높이 균등 + 콘텐츠 비율로 결정된 패널에 이미지 contain.
   패널(pn.w×pn.h)이 이미 콘텐츠 비율과 일치 → objectFit:cover(zoom=1) = 전체 이미지(크롭 없음).
   사용자가 휠로 수동 확대 시 커버크롭 영역을 드래그로 이동 가능. */
function SplitStage({ panels, focalOf, onFocal, onSwap, onBegin, dispW = 460, logos, onDeletePanel, guideOn = true, selLogoIdx, onSelectLogo, imageAdjust, imageAdjustBySource }) {
  const scale = dispW / 550;
  const panRef = React.useRef(null);
  const swapDragRef = React.useRef(null);
  const swapOverRef = React.useRef(null);
  const [menu, setMenu] = React.useState(null);
  const [panelZ, setPanelZ] = React.useState({});
  const [resizePanel, setResizePanel] = React.useState(null);
  React.useEffect(() => {
    if (!menu) return;
    const close = () => setMenu(null);
    window.addEventListener('pointerdown', close);
    return () => window.removeEventListener('pointerdown', close);
  }, [menu]);
  // 수동 pan(zoom>1 일 때 2축 이동) + 휠/모서리 확대. 초기 zoom=1 → 전체 보기.
  const startPan = (e, i, pn) => {
    if (e.button === 2) return;
    if (onFocal) setResizePanel(i);
    if (!onFocal) return;
    const cur = focalOf(i, pn.focal);
    if ((cur.zoom || 1) <= 1) return; // zoom=1 이면 이동할 여백 없음
    onBegin && onBegin();
    e.preventDefault();e.stopPropagation();
    const rect = e.currentTarget.getBoundingClientRect();
    panRef.current = { sx: e.clientX, sy: e.clientY, x: cur.x, y: cur.y, z: cur.zoom || 1, w: rect.width, h: rect.height };
    const mv = (ev) => {const d = panRef.current;if (!d) return;const nx = clampPct(d.x - (ev.clientX - d.sx) / d.w * 100);const ny = clampPct(d.y - (ev.clientY - d.sy) / d.h * 100);onFocal(i, { x: nx, y: ny, zoom: d.z });};
    const up = () => {panRef.current = null;window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const onWheel = (e, i, pn) => {if (!onFocal) return;e.preventDefault();e.stopPropagation();const cur = focalOf(i, pn.focal);onBegin && onBegin();onFocal(i, { x: cur.x, y: cur.y, zoom: Math.max(1, Math.min(3, +((cur.zoom || 1) - e.deltaY * 0.0015).toFixed(3))) });};
  const startResize = (e, i, pn) => {if (e.button === 2) return;if (!onFocal) return;onBegin && onBegin();e.preventDefault();e.stopPropagation();const cur = focalOf(i, pn.focal);const sz = cur.zoom || 1, sx = e.clientX, sy = e.clientY;const mv = (ev) => {const d = (ev.clientX - sx + (ev.clientY - sy)) / 300;onFocal(i, { x: cur.x, y: cur.y, zoom: Math.max(1, Math.min(3, +(sz + d).toFixed(3))) });};const up = () => {window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);};
  const openPanelMenu = (e, i) => {
    e.preventDefault(); e.stopPropagation();
    setMenu({ x: e.clientX, y: e.clientY, i });
  };
  const openPanelMenuAtPoint = (e) => {
    e.preventDefault(); e.stopPropagation();
    const wrap = e.currentTarget.getBoundingClientRect();
    const x = (e.clientX - wrap.left) / scale;
    const y = (e.clientY - wrap.top) / scale;
    const hit = panels
      .map((pn, i) => ({ pn, i, z: panelZ[i] != null ? panelZ[i] : i + 1 }))
      .filter(({ pn }) => x >= pn.x && x <= pn.x + pn.w && y >= pn.y && y <= pn.y + pn.h)
      .sort((a, b) => b.z - a.z)[0];
    if (!hit) return;
    setMenu({ x: e.clientX, y: e.clientY, i: hit.i });
  };
  const changePanelZ = (i, dir) => {
    setPanelZ((p) => {
      const vals = panels.map((_, k) => p[k] != null ? p[k] : k + 1);
      const nextZ = dir === 'front' ? Math.max.apply(null, vals) + 1 : Math.min.apply(null, vals) - 1;
      return { ...p, [i]: nextZ };
    });
    setMenu(null);
  };
  const resizeFromMenu = () => { if (!menu) return; setResizePanel(menu.i); setMenu(null); };
  const deleteFromMenu = () => {
    if (!menu || !onDeletePanel) return;
    onDeletePanel(panels[menu.i]);
    setMenu(null);
  };
  const startSwapPointer = (e, i) => {
    if (!onSwap || e.button !== 0) return;
    const sx = e.clientX, sy = e.clientY;
    const clearOver = () => {if (swapOverRef.current) {swapOverRef.current.classList.remove('drop-over');swapOverRef.current = null;}};
    const panelAt = (ev) => {
      const el = document.elementFromPoint(ev.clientX, ev.clientY);
      return el && el.closest ? el.closest('[data-split-panel-index]') : null;
    };
    const mv = (ev) => {
      const el = panelAt(ev);
      const to = el ? parseInt(el.getAttribute('data-split-panel-index'), 10) : NaN;
      clearOver();
      if (el && !isNaN(to) && to !== i) {el.classList.add('drop-over');swapOverRef.current = el;}
    };
    const up = (ev) => {
      window.removeEventListener('pointermove', mv);
      window.removeEventListener('pointerup', up);
      const moved = Math.abs(ev.clientX - sx) + Math.abs(ev.clientY - sy);
      const el = panelAt(ev);
      const to = el ? parseInt(el.getAttribute('data-split-panel-index'), 10) : NaN;
      clearOver();
      if (moved >= 8 && !isNaN(to) && to !== i) {onBegin && onBegin();onSwap(i, to);}
    };
    window.addEventListener('pointermove', mv);
    window.addEventListener('pointerup', up);
  };
  const swapHandlers = (i) => !onSwap ? {} : ({
    draggable: true,
    onDragStart: (e) => {swapDragRef.current = i;e.dataTransfer.effectAllowed = 'move';e.dataTransfer.setData('text/plain', 'split-panel:' + i);},
    onDragOver: (e) => {const from = swapDragRef.current;if (from == null || from === i) return;e.preventDefault();e.dataTransfer.dropEffect = 'move';e.currentTarget.classList.add('drop-over');},
    onDragLeave: (e) => e.currentTarget.classList.remove('drop-over'),
    onDrop: (e) => {e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-over');const d = e.dataTransfer.getData('text/plain') || '';const from = d.indexOf('split-panel:') === 0 ? parseInt(d.slice(12), 10) : swapDragRef.current;if (!isNaN(from) && from !== i) {onBegin && onBegin();onSwap(from, i);}swapDragRef.current = null;},
    onDragEnd: (e) => {e.currentTarget.classList.remove('drop-over');swapDragRef.current = null;},
    title: '다른 컷 위로 드래그하면 이미지 위치가 바뀝니다'
  });
  const menuPortal = menu && document.body ? (() => {
    const w = 168, h = onDeletePanel ? 150 : 114;
    const left = Math.max(8, Math.min(window.innerWidth - w - 8, menu.x));
    const top = Math.max(8, Math.min(window.innerHeight - h - 8, menu.y));
    return ReactDOM.createPortal(
      <div className="ap-ctx" style={{ position: 'fixed', left, top, zIndex: 100000 }} onPointerDown={(e) => e.stopPropagation()}>
        <button className="ap-ctx-item" onPointerDown={() => changePanelZ(menu.i, 'front')}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5M5 12l7-7 7 7" /></svg>앞으로
        </button>
        <button className="ap-ctx-item" onPointerDown={() => changePanelZ(menu.i, 'back')}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12l7 7 7-7" /></svg>뒤로
        </button>
        <button className="ap-ctx-item" onPointerDown={resizeFromMenu}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 9V4h5" /><path d="M20 15v5h-5" /><path d="M4 4l6 6" /><path d="M20 20l-6-6" /></svg>사이즈 변경
        </button>
        {onDeletePanel && (
          <button className="ap-ctx-item" onPointerDown={deleteFromMenu}>
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /></svg>삭제
          </button>
        )}
      </div>,
      document.body
    );
  })() : null;
  return (
    <div className="ap-stage-wrap" onContextMenu={openPanelMenuAtPoint} onPointerDown={(e) => { if (onSelectLogo && !(e.target && e.target.getAttribute && e.target.getAttribute('data-logo-idx') != null)) onSelectLogo(null); }} style={{ width: dispW, height: dispW, position: 'relative', overflow: 'hidden', borderRadius: 0, background: '#fff' }}>
      {panels.map((pn, i) => {const f = focalOf(i, pn.focal);const z = f.zoom || 1;const canPan = onFocal && z > 1;return (
        <div key={i} data-split-panel-index={i} data-photo-panel-selected={resizePanel === i ? 'true' : 'false'} {...swapHandlers(i)} onPointerDown={(e) => {startSwapPointer(e, i);startPan(e, i, pn);}} onContextMenu={(e) => openPanelMenu(e, i)} onWheel={(e) => onWheel(e, i, pn)} style={{ position: 'absolute', left: pn.x * scale, top: pn.y * scale, width: pn.w * scale, height: pn.h * scale, overflow: 'hidden', cursor: onSwap ? 'grab' : canPan ? 'grab' : 'default', touchAction: 'none', background: '#fff', zIndex: panelZ[i] != null ? panelZ[i] : i + 1, boxShadow: resizePanel === i ? 'inset 0 0 0 1.5px #4E4CDB' : 'inset 0 0 0 0.5px rgba(0,0,0,.08)' }}>
          <img src={pn.url} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: f.x + '% ' + f.y + '%', transform: z !== 1 ? `scale(${z})` : undefined, transformOrigin: z !== 1 ? (f.x + '% ' + f.y + '%') : undefined, pointerEvents: 'none', ...((window.BannerImageTools && window.BannerImageTools.imageStyle(window.BannerImageTools.forSource ? window.BannerImageTools.forSource(imageAdjust, imageAdjustBySource, pn.src || pn.url) : imageAdjust)) || {}) }} />
          {onFocal && <div onPointerDown={(e) => startResize(e, i, pn)} title="휠로 확대 · 모서리 드래그로 크기 조절" style={{ position: 'absolute', right: 0, bottom: 0, width: 15, height: 15, cursor: 'nwse-resize', zIndex: 6, touchAction: 'none', background: 'linear-gradient(135deg, transparent 48%, #4E4CDB 48%, #4E4CDB 64%, transparent 64%, transparent 76%, #4E4CDB 76%, #4E4CDB 90%, transparent 90%)' }} />}
        </div>);})}
      {guideOn && (
        <div data-safe-guide="split" style={{ position: 'absolute', inset: 0, width: 550, height: 550, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none', zIndex: 1000 }}>
          <SafeGuideOverlay sx={50} sy={40} cw={550} ch={550} />
        </div>
      )}
      <div style={{ position: 'absolute', top: 0, left: 0, width: 550, height: 550, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none' }}>
        {apOverlayEls(logos, null, null, null, selLogoIdx, onSelectLogo)}
      </div>
      {menuPortal}
    </div>);

}
/* B 분할+누끼형 스테이지 — 좌 3/5에 누끼 클러스터, 우측 분할컷은 고정하고 화보 이미지만 확대/초점 조정. */
function SplitNukkiStage({ rects, editable, selectedId, onSelect, onChange, onBegin, bounds, scorer, photo, focal, onFocal, split = 330, dispW = 460, logos, onDeleteItem, onDeletePhoto, guideOn = true, selLogoIdx, onSelectLogo, onFocalBegin, imageAdjust, imageAdjustBySource, onSelectionChange }) {
  const scale = dispW / 550;
  const zoom = focal && focal.zoom ? focal.zoom : 1;
  const panRef = React.useRef(null);
  const pw = 550 - split;
  const pf = { x: split, y: 0, w: pw, h: 550 };
  const [photoMenu, setPhotoMenu] = React.useState(null);
  const [photoZ, setPhotoZ] = React.useState(50);
  const [photoSel, setPhotoSel] = React.useState(false);
  React.useEffect(() => {
    if (!photoMenu) return;
    const close = () => setPhotoMenu(null);
    window.addEventListener('pointerdown', close);
    return () => window.removeEventListener('pointerdown', close);
  }, [photoMenu]);
  React.useEffect(() => { if (selectedId != null) setPhotoSel(false); }, [selectedId]);
  const beginPhotoFocal = () => { if (onFocalBegin) onFocalBegin(); };
  const setPhotoZoom = (nz) => {
    if (!onFocal) return;
    onFocal({ x: 50, y: 50, zoom: Math.max(1, Math.min(3, +nz.toFixed(3))) });
  };
  const onWheel = (e) => {if (!onFocal) return;e.preventDefault();e.stopPropagation();beginPhotoFocal();setPhotoZoom(zoom - e.deltaY * 0.0015);};
  const startPhotoZoom = (e) => {
    if (!editable || !onFocal || e.button === 2) return;
    e.preventDefault();e.stopPropagation();
    beginPhotoFocal();
    const sz = zoom, sx = e.clientX, sy = e.clientY;
    const mv = (ev) => {
      const d = panRef.current;if (!d) return;
      const delta = (ev.clientX - d.sx + (ev.clientY - d.sy)) / 300;
      setPhotoZoom(d.zoom + delta);
    };
    const up = () => {panRef.current = null;window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    panRef.current = { sx, sy, zoom: sz };
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const openPhotoMenu = (e) => {
    if (!editable) return;
    e.preventDefault(); e.stopPropagation();
    setPhotoSel(true); onSelect && onSelect(null);
    setPhotoMenu({ x: e.clientX, y: e.clientY });
  };
  const openStageMenuAtPoint = (e) => {
    if (!editable) return;
    e.preventDefault(); e.stopPropagation();
    const stage = e.currentTarget.getBoundingClientRect();
    const x = (e.clientX - stage.left) / scale;
    const y = (e.clientY - stage.top) / scale;
    if (photo && x >= split && x <= 550 && y >= 0 && y <= 550) {
      setPhotoSel(true); onSelect && onSelect(null);
      setPhotoMenu({ x: e.clientX, y: e.clientY });
    }
  };
  const closePhotoMenu = () => setPhotoMenu(null);
  const resizePhotoFromMenu = () => { setPhotoSel(true); closePhotoMenu(); };
  const deletePhotoFromMenu = () => { if (onDeletePhoto) onDeletePhoto(); closePhotoMenu(); };
  const handleProductSelect = (id) => { setPhotoSel(false); onSelect && onSelect(id); if (id != null && onSelectLogo) onSelectLogo(null); };
  const handlePhotoDown = (e) => {
    if (e.button === 2 || !editable) return;
    e.stopPropagation();
    setPhotoSel(true);
    onSelect && onSelect(null);
    onSelectLogo && onSelectLogo(null);
  };
  const photoMenuPortal = photoMenu && document.body ? (() => {
    const w = 168, h = onDeletePhoto ? 150 : 114;
    const left = Math.max(8, Math.min(window.innerWidth - w - 8, photoMenu.x));
    const top = Math.max(8, Math.min(window.innerHeight - h - 8, photoMenu.y));
    return ReactDOM.createPortal(
      <div className="ap-ctx" style={{ position: 'fixed', left, top, zIndex: 100000 }} onPointerDown={(e) => e.stopPropagation()}>
        <button className="ap-ctx-item" onPointerDown={() => { setPhotoZ(65); closePhotoMenu(); }}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5M5 12l7-7 7 7" /></svg>앞으로
        </button>
        <button className="ap-ctx-item" onPointerDown={() => { setPhotoZ(5); closePhotoMenu(); }}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12l7 7 7-7" /></svg>뒤로
        </button>
        <button className="ap-ctx-item" onPointerDown={resizePhotoFromMenu}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 9V4h5" /><path d="M20 15v5h-5" /><path d="M4 4l6 6" /><path d="M20 20l-6-6" /></svg>사이즈 변경
        </button>
        {onDeletePhoto && (
          <button className="ap-ctx-item" onPointerDown={deletePhotoFromMenu}>
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /></svg>삭제
          </button>
        )}
      </div>,
      document.body
    );
  })() : null;
  // 좌측 누끼 클러스터 = A 편집기(APStage) 그대로 재사용(드래그·크기·순서·reflow). 우측 화보는 고정 패널 안에서 확대/축소만 허용.
  // APStage는 faintSafe:true(누끼 영역 가이드 억제) → 여백 오버레이는 아래 logos 레이어(z=52)에서 전체 캔버스 커버.
  return (
    <div onContextMenu={openStageMenuAtPoint} style={{ position: 'relative', width: dispW, height: dispW, margin: '0 auto', overflow: 'hidden', isolation: 'isolate' }}>
      {React.createElement(window.APStage, { rects, dispW, editable, selectedId, onSelect: handleProductSelect, onChange, onBegin, bounds, scorer, faintSafe: true, logos: [], onDeleteItem: editable ? onDeleteItem : null, imageAdjust, imageAdjustBySource, onSelectionChange })}
      {photo && <div data-photo-panel="split-nukki" onPointerDown={handlePhotoDown} onWheel={onWheel} onContextMenu={openPhotoMenu} style={{ position: 'absolute', left: pf.x * scale, top: pf.y * scale, width: pf.w * scale, height: pf.h * scale, overflow: 'hidden', cursor: editable ? 'pointer' : 'default', touchAction: 'none', zIndex: photoZ }}>
        <img src={photo.url} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: '50% 50%', transform: `scale(${zoom})`, transformOrigin: '50% 50%', pointerEvents: 'none', ...((window.BannerImageTools && window.BannerImageTools.imageStyle(window.BannerImageTools.forSource ? window.BannerImageTools.forSource(imageAdjust, imageAdjustBySource, photo.src || photo.url) : imageAdjust)) || {}) }} />
        {editable && onFocal && <div onPointerDown={startPhotoZoom} title="휠 또는 드래그로 화보 확대/축소" aria-label="화보 확대/축소" style={{ position: 'absolute', right: 0, bottom: 0, width: 18, height: 18, cursor: 'nwse-resize', zIndex: 66, touchAction: 'none', background: 'linear-gradient(135deg, transparent 48%, #4E4CDB 48%, #4E4CDB 64%, transparent 64%, transparent 76%, #4E4CDB 76%, #4E4CDB 90%, transparent 90%)' }} />}
        {zoom > 1.001 && <span style={{ position: 'absolute', left: 6, bottom: 6, zIndex: 60, background: 'rgba(28,28,34,.6)', color: '#fff', fontSize: 10, fontWeight: 700, borderRadius: 5, padding: '1px 6px', pointerEvents: 'none' }}>{Math.round(zoom * 100)}%</span>}
        {editable && onDeletePhoto && (
          <button
            type="button"
            title="화보 삭제"
            aria-label="화보 삭제"
            onPointerDown={(e) => { e.stopPropagation(); e.preventDefault(); if (e.nativeEvent && e.nativeEvent.stopImmediatePropagation) e.nativeEvent.stopImmediatePropagation(); }}
            onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); if (e.nativeEvent && e.nativeEvent.stopImmediatePropagation) e.nativeEvent.stopImmediatePropagation(); }}
            onPointerUp={(e) => { e.stopPropagation(); e.preventDefault(); onDeletePhoto(); }}
            onClick={(e) => { e.stopPropagation(); e.preventDefault(); onDeletePhoto(); }}
            style={{ position: 'absolute', top: 0, right: 0, minWidth: 22, minHeight: 22, width: 22, height: 22, background: 'rgba(28,28,34,.82)', border: 'none', borderRadius: '0 0 0 4px', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, zIndex: 55, lineHeight: 1, pointerEvents: 'auto', touchAction: 'none' }}
          >
            <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
          </button>
        )}
      </div>}
      {/* 화보 조정박스 — 사이즈 변경/우클릭 선택 시 노출. z=57. SE 핸들: startPhotoZoom 재사용. */}
      {editable && photoSel && photo && <>
        <div data-photo-selection-tool="split-nukki" style={{ position: 'absolute', left: pf.x * scale, top: 0, width: pf.w * scale, height: dispW, border: '1.5px solid #4E4CDB', boxSizing: 'border-box', pointerEvents: 'none', zIndex: 57 }} />
        <div onPointerDown={startPhotoZoom} title="드래그로 화보 확대/축소" style={{ position: 'absolute', left: dispW - 5, top: dispW - 5, width: 10, height: 10, background: '#4E4CDB', border: '1.5px solid #fff', boxSizing: 'border-box', cursor: 'nwse-resize', zIndex: 58, touchAction: 'none' }} />
        <div onPointerDown={(e) => { e.stopPropagation(); }} style={{ position: 'absolute', left: (pf.x * scale + 10), bottom: 10, width: Math.max(120, pf.w * scale - 20), padding: '7px 8px', borderRadius: 7, background: 'rgba(255,255,255,.94)', boxShadow: '0 1px 6px rgba(0,0,0,.16)', zIndex: 59 }}>
          <input type="range" min="1" max="3" step="0.01" value={zoom} aria-label="화보 확대/축소 값" onPointerDown={(e) => { e.stopPropagation(); beginPhotoFocal(); }} onChange={(e) => setPhotoZoom(parseFloat(e.target.value))} style={{ width: '100%', display: 'block', accentColor: '#4E4CDB' }} />
        </div>
      </>}
      {/* 여백 가이드 + 로고 오버레이 — z=52 (photo z=50 위) → 전체 캔버스(누끼+화보) 커버 */}
      <div style={{ position: 'absolute', top: 0, left: 0, width: 550, height: 550, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none', zIndex: 52 }}>
        {guideOn && <SafeGuideOverlay sx={50} sy={40} cw={550} ch={550} />}
        {apOverlayEls(logos, null, null, null, selLogoIdx, onSelectLogo)}
      </div>
      {/* 박스 아웃라인 = 콘텐츠(누끼+화보) 전체를 감싸는 하나. 누끼/화보 분할선은 표시 안 함. */}
      <div style={{ position: 'absolute', inset: 0, border: '1.5px solid #cccccc', borderRadius: '4px', pointerEvents: 'none', zIndex: 55 }} />
      {photoMenuPortal}
    </div>);

}
/* E 보험 스테이지 — 로고+보험명 좌상단, 일러스트 중앙-하단, 모델(선택) 우측 밴드(cover+초점), 부가정보 좌하단.
   eLayout(단일 출처)으로 위치 계산 → 미리보기·다운로드 동일. */
function EStage({ name, illust, illustContain, model, focal, onFocal, onBegin, logos, deco, decoPos, onDecoPosChange, onDecoScale, decoSelected, onSelectDeco, onDeleteIllust, onDeleteModel, bg, textColor, dispW = 460, guideOn = true, selLogoIdx, onSelectLogo, imageAdjust, imageAdjustBySource }) {
  const scale = dispW / 550;
  const f = focal || { x: 50, y: 50 };
  const L = eLayout({ logoMeta: logos, illustAspect: illust && illust.aspect, illustContain, nameLines: String(name || '').split(String.fromCharCode(10)).map((s) => s.trim()).filter(Boolean).length, hasModel: !!model, modelAspect: model && model.aspect, deco });
  const modelBox = eScaledBox(L.model, f.modelZoom, 'right-bottom');
  const illustBox = eScaledBox(L.illust, f.illustZoom, 'center-bottom');
  const modelFront = f.modelFront !== false;
  const panRef = React.useRef(null);
  const decoDrag = React.useRef(null);
  const [assetMenu, setAssetMenu] = React.useState(null);
  const [resizeTarget, setResizeTarget] = React.useState(null);
  React.useEffect(() => {
    if (!assetMenu) return;
    const close = () => setAssetMenu(null);
    window.addEventListener('pointerdown', close);
    return () => window.removeEventListener('pointerdown', close);
  }, [assetMenu]);
  const clampDecoPos = (p, d) => ({ x: Math.max(0, Math.min(550 - (d.w || 90), Number(p && p.x) || 0)), y: Math.max(0, Math.min(550 - (d.h || 90), Number(p && p.y) || 0)) });
  const defaultDecoPos = L.decoBox ? { x: L.decoBox.x, y: L.decoBox.y } : { x: 0, y: 0 };
  const currentDecoPos = deco && L.decoBox ? clampDecoPos(decoPos && typeof decoPos === 'object' ? decoPos : defaultDecoPos, deco) : null;
  const ov = model && modelBox ? ((model.aspect || 0.6) > modelBox.w / modelBox.h + 1e-3 ? 'x' : (model.aspect || 0.6) < modelBox.w / modelBox.h - 1e-3 ? 'y' : null) : null;
  const startPan = (e) => {
    if (e.button === 2) return;
    setResizeTarget('model');
    if (!onFocal || !ov) return;
    onBegin && onBegin();
    e.preventDefault();
    const rect = e.currentTarget.getBoundingClientRect();
    panRef.current = { sx: e.clientX, sy: e.clientY, x: f.x, y: f.y, w: rect.width, h: rect.height };
    const mv = (ev) => {const d = panRef.current;if (!d) return;const nx = ov === 'x' ? clampPct(d.x - (ev.clientX - d.sx) / d.w * 100) : 50;const ny = ov === 'y' ? clampPct(d.y - (ev.clientY - d.sy) / d.h * 100) : 50;onFocal({ ...f, x: nx, y: ny });};
    const up = () => {panRef.current = null;window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const startAssetResize = (e, target) => {
    if (e.button === 2 || !onFocal) return;
    e.preventDefault();e.stopPropagation();
    setResizeTarget(target);
    onBegin && onBegin();
    const key = target === 'model' ? 'modelZoom' : 'illustZoom';
    const startZoom = Number(f[key]) || 1, sx = e.clientX, sy = e.clientY;
    const mv = (ev) => {const delta = (ev.clientX - sx + (ev.clientY - sy)) / 260;onFocal({ ...f, [key]: Math.max(0.5, Math.min(2.5, +(startZoom + delta).toFixed(3))) });};
    const up = () => {window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const lines = String(name || '').split(String.fromCharCode(10)).map((s) => s.trim()).filter(Boolean);
  const startDecoDrag = (e) => {
    if (!deco || !onDecoPosChange || !currentDecoPos || e.button === 2) return;
    e.preventDefault();e.stopPropagation();
    onSelectDeco && onSelectDeco();
    decoDrag.current = { sx: e.clientX, sy: e.clientY, orig: currentDecoPos };
    const mv = (ev) => {const d = decoDrag.current;if (!d) return;onDecoPosChange(clampDecoPos({ x: d.orig.x + (ev.clientX - d.sx) / scale, y: d.orig.y + (ev.clientY - d.sy) / scale }, deco));};
    const up = () => {decoDrag.current = null;window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const startDecoResize = (e) => {
    if (!deco || !onDecoScale || e.button === 2) return;
    e.preventDefault();e.stopPropagation();
    onSelectDeco && onSelectDeco();
    const sx = e.clientX, sy = e.clientY, orig = Number(deco.scale) || 1;
    const mv = (ev) => onDecoScale(Math.max(0.5, Math.min(2.4, +(orig + ((ev.clientX - sx) + (ev.clientY - sy)) / 180).toFixed(3))));
    const up = () => {window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const openAssetMenu = (e, target) => {e.preventDefault();e.stopPropagation();setAssetMenu({ x: e.clientX, y: e.clientY, target });};
  const closeAssetMenu = () => setAssetMenu(null);
  const moveAsset = (target, direction) => {
    if (onFocal) {onBegin && onBegin();onFocal({ ...f, modelFront: target === 'model' ? direction === 'front' : direction !== 'front' });}
    closeAssetMenu();
  };
  const resizeFromMenu = () => {if (assetMenu) setResizeTarget(assetMenu.target);closeAssetMenu();};
  const deleteFromMenu = () => {
    if (!assetMenu) return;
    const target = assetMenu.target;
    if (onFocal) onFocal({ ...f, [target === 'model' ? 'modelZoom' : 'illustZoom']: 1 });
    if (target === 'model') {if (onDeleteModel) onDeleteModel();} else if (onDeleteIllust) onDeleteIllust();
    setResizeTarget(null);closeAssetMenu();
  };
  const canDelete = assetMenu && (assetMenu.target === 'model' ? onDeleteModel : onDeleteIllust);
  const assetMenuPortal = assetMenu && document.body ? (() => {
    const w = 168, h = canDelete ? 150 : 114;
    const left = Math.max(8, Math.min(window.innerWidth - w - 8, assetMenu.x));
    const top = Math.max(8, Math.min(window.innerHeight - h - 8, assetMenu.y));
    return ReactDOM.createPortal(
      <div className="ap-ctx" data-context-menu-target={assetMenu.target} style={{ position: 'fixed', left, top, zIndex: 100000 }} onPointerDown={(e) => e.stopPropagation()}>
        <button className="ap-ctx-item" onPointerDown={() => moveAsset(assetMenu.target, 'front')}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5M5 12l7-7 7 7" /></svg>앞으로</button>
        <button className="ap-ctx-item" onPointerDown={() => moveAsset(assetMenu.target, 'back')}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12l7 7 7-7" /></svg>뒤로</button>
        <button className="ap-ctx-item" onPointerDown={resizeFromMenu}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 9V4h5" /><path d="M20 15v5h-5" /><path d="M4 4l6 6" /><path d="M20 20l-6-6" /></svg>사이즈 변경</button>
        {canDelete && <button className="ap-ctx-item" onPointerDown={deleteFromMenu}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /></svg>삭제</button>}
      </div>, document.body
    );
  })() : null;
  const contentZ = modelFront ? 20 : 30;
  return (
    <div className="ap-stage-wrap" onPointerDown={(e) => { if (onSelectLogo && !(e.target && e.target.getAttribute && e.target.getAttribute('data-logo-idx') != null)) onSelectLogo(null); }} style={{ width: dispW, height: dispW, position: 'relative', overflow: 'hidden', borderRadius: 0, background: bg || '#fff' }}>
      {illust && illustBox && <div data-context-asset="insurance-illustration" onPointerDown={(e) => {if (e.button !== 2) {e.stopPropagation();setResizeTarget('illust');}}} onContextMenu={(e) => openAssetMenu(e, 'illust')} style={{ position: 'absolute', left: illustBox.x * scale, top: illustBox.y * scale, width: illustBox.w * scale, height: illustBox.h * scale, zIndex: modelFront ? 10 : 20, touchAction: 'none' }}>
        <img src={illust.url} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: illustBox.contain ? 'contain' : 'cover', objectPosition: illustBox.anchorBottom ? 'center bottom' : 'center', pointerEvents: 'none', ...((window.BannerImageTools && window.BannerImageTools.imageStyle(window.BannerImageTools.forSource ? window.BannerImageTools.forSource(imageAdjust, imageAdjustBySource, illust.src || illust.url) : imageAdjust)) || {}) }} />
        {resizeTarget === 'illust' && <><div style={{ position: 'absolute', inset: 0, border: '1.5px solid #4E4CDB', boxSizing: 'border-box', pointerEvents: 'none' }} /><div onPointerDown={(e) => startAssetResize(e, 'illust')} title="모서리를 드래그해 크기 조절" style={{ position: 'absolute', right: -7, bottom: -7, width: 14, height: 14, border: '2px solid #fff', borderRadius: 2, background: '#4E4CDB', cursor: 'nwse-resize', touchAction: 'none' }} /></>}
      </div>}
      <div style={{ position: 'absolute', top: 0, left: 0, width: 550, height: 550, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none', zIndex: contentZ }}>
        {L.logos.map((lg, i) => <img key={i} data-logo-idx={i} src={lg.url} alt="" draggable={false} onPointerDown={(e) => { if (!onSelectLogo) return; e.stopPropagation(); e.preventDefault(); onSelectLogo(i); }} style={{ position: 'absolute', left: lg.x, top: lg.y, width: lg.w, height: lg.h, objectFit: 'contain', pointerEvents: onSelectLogo ? 'auto' : 'none', cursor: onSelectLogo ? 'pointer' : 'default', outline: selLogoIdx === i ? '1.5px solid #4E4CDB' : 'none', outlineOffset: 2 }} />)}
        {lines.length > 0 && <div style={{ position: 'absolute', left: L.nameX, top: L.nameY, width: L.nameRight - L.nameX, textAlign: L.nameCenter ? 'center' : 'left', color: textColor || '#1c2b4a', fontSize: (L.nameFont || 26) + 'px', fontWeight: 700, lineHeight: 1.2, letterSpacing: '-0.025em', fontFamily: '"Noto Sans KR", "Noto Sans CJK KR", sans-serif' }}>{lines.map((ln, i) => <div key={i}>{ln}</div>)}</div>}
      </div>
      {model && modelBox && <div data-context-asset="insurance-model" onPointerDown={startPan} onContextMenu={(e) => openAssetMenu(e, 'model')} style={{ position: 'absolute', left: modelBox.x * scale, top: modelBox.y * scale, width: modelBox.w * scale, height: modelBox.h * scale, zIndex: modelFront ? 30 : 10, cursor: ov ? 'grab' : 'default', touchAction: 'none' }}>
        <img src={model.url} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', objectPosition: 'left top', pointerEvents: 'none', ...((window.BannerImageTools && window.BannerImageTools.imageStyle(window.BannerImageTools.forSource ? window.BannerImageTools.forSource(imageAdjust, imageAdjustBySource, model.src || model.url) : imageAdjust)) || {}) }} />
        {resizeTarget === 'model' && <><div style={{ position: 'absolute', inset: 0, border: '1.5px solid #4E4CDB', boxSizing: 'border-box', pointerEvents: 'none' }} /><div onPointerDown={(e) => startAssetResize(e, 'model')} title="모서리를 드래그해 크기 조절" style={{ position: 'absolute', right: -7, bottom: -7, width: 14, height: 14, border: '2px solid #fff', borderRadius: 2, background: '#4E4CDB', cursor: 'nwse-resize', touchAction: 'none' }} /></>}
      </div>}
      {deco && currentDecoPos && window.DecoBadge && (() => {
        const ds = Number(deco.scale) || 1;
        const bw = deco.baseW || (deco.w || 90) / ds;
        const bh = deco.baseH || (deco.h || 90) / ds;
        return <div data-deco-overlay="550" onPointerDown={startDecoDrag} style={{ position: 'absolute', left: currentDecoPos.x * scale, top: currentDecoPos.y * scale, width: (deco.w || bw * ds) * scale, height: (deco.h || bh * ds) * scale, zIndex: 40, pointerEvents: onDecoPosChange ? 'auto' : 'none', cursor: onDecoPosChange ? 'move' : 'default', touchAction: 'none', userSelect: 'none', outline: decoSelected ? '1.5px solid #4E4CDB' : 'none', outlineOffset: 3 }}>
          <div style={{ width: bw, height: bh, transform: `scale(${ds * scale})`, transformOrigin: 'top left' }}>{React.createElement(window.DecoBadge, { deco })}</div>
          {decoSelected && onDecoScale && <span data-deco-resize="550" title="부가정보 크기 조절" onPointerDown={startDecoResize} style={{ position: 'absolute', right: -8, bottom: -8, width: 15, height: 15, border: '2px solid #fff', borderRadius: 2, background: '#4E4CDB', cursor: 'nwse-resize', boxShadow: '0 1px 5px rgba(0,0,0,.18)' }} />}
        </div>;
      })()}
      {guideOn && <div style={{ position: 'absolute', top: 0, left: 0, width: 550, height: 550, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none', zIndex: 200 }}><SafeGuideOverlay sx={E_CFG.safeX} sy={E_CFG.safeY} cw={550} ch={550} /></div>}
      {assetMenuPortal}
    </div>);
}
/* [de-iframe 격리] IIFE 종료 — 밖으로는 이것 하나만 노출 */
window.AutoPlace550 = AutoPlaceTyped;
})();
