/* nukki-h1/app-typed.jsx — 상품배너 H1 편집기 (nukki550/app-typed.jsx 완전 복사본)
   · 유형별 독립: 550 편집기·로직 복사, 캔버스만 776×388(가로형). 엔진 window.AP_H1 / 캔버스 *H1 / window.AutoPlaceH1. 550 무접촉. */
/* ============================================================
   app-typed.jsx — 상품배너 H1 · 유형별 자동배치 (개선 시안 v2)
   좌측 인스펙터 = 필수 (유형 → 카테고리(자동) → 단일 이미지 업로드[상품/모델 자동 분류] → ▸고급)
   우측 인스펙터 = 선택 (로고 + 택1: 부가텍스트/부가이미지/인증플래그/이미지표기)
   단일 업로드 영역: 각 이미지를 비전으로 판별 → 인물=모델(우측 밴드) / 그 외=상품(좌측 클러스터).
   A 누끼형은 엔진(window.AP_H1)+캔버스 재사용. B~E 셸.
   ============================================================ */
/* [de-iframe 격리] 이 파일 전체를 IIFE로 감싼다 — 전역 이름(BANNER_TYPES·useS2 등)이
   메인 앱(merged/*)과 충돌하지 않도록. 밖으로는 파일 끝에서 window.AutoPlaceH1 하나만 노출.
   canvas.jsx 컴포넌트(APResultCardH1 등)는 전역이라 IIFE 안에서 그대로 읽힌다. */
(function () {
const { useState: useS2, useRef: useSR2, useEffect: useSE2, useMemo: useSM2 } = React;
function setHighQualityImageSmoothing(ctx) {
  if (!ctx) return;
  ctx.imageSmoothingEnabled = true;
  try { ctx.imageSmoothingQuality = 'high'; } catch (e) {}
}
function drawAdjusted(ctx, image, adjustments) {
  const args = Array.prototype.slice.call(arguments, 3);
  if (window.BannerImageTools && adjustments) {
    window.BannerImageTools.drawImage.apply(null, [ctx, image, adjustments].concat(args));
    return;
  }
  ctx.drawImage.apply(ctx, [image].concat(args));
}
function drawAdjustedRotated(ctx, image, adjustments, x, y, w, h, rotate) {
  const deg = Number(rotate) || 0;
  if (!deg) { drawAdjusted(ctx, image, adjustments, x, y, w, h); return; }
  ctx.save();
  ctx.translate(x + w / 2, y + h / 2);
  ctx.rotate(deg * Math.PI / 180);
  drawAdjusted(ctx, image, adjustments, -w / 2, -h / 2, w, h);
  ctx.restore();
}
function coverSourceRect(natW, natH, dw, dh, focal) {
  const scale = Math.max(dw / Math.max(1, natW), dh / Math.max(1, natH));
  const sw = dw / scale;
  const sh = dh / scale;
  const fx = (focal && focal.x != null ? focal.x : 50) / 100;
  const fy = (focal && focal.y != null ? focal.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,
  };
}
function containDestRect(natW, natH, dw, dh, align) {
  const scale = Math.min(dw / Math.max(1, natW), dh / Math.max(1, natH));
  const w = natW * scale;
  const h = natH * scale;
  const ax = align && align.x === 'right' ? 1 : align && align.x === 'left' ? 0 : 0.5;
  const ay = align && align.y === 'bottom' ? 1 : align && align.y === 'top' ? 0 : 0.5;
  return {
    x: -dw / 2 + (dw - w) * ax,
    y: -dh / 2 + (dh - h) * ay,
    w,
    h,
  };
}
function drawRectAdjusted(ctx, image, adjustments, r) {
  const deg = Number(r && r.rotate) || 0;
  const fitCover = r && r.fit === 'cover';
  const draw = () => {
    if (fitCover) {
      const c = coverSourceRect(image.naturalWidth, image.naturalHeight, r.w, r.h, r.focal || { x: 50, y: 0 });
      drawAdjusted(ctx, image, adjustments, c.sx, c.sy, c.sw, c.sh, -r.w / 2, -r.h / 2, r.w, r.h);
    } else {
      const align = r && r.id === '__modelCorner' ? { x: 'right', y: 'top' } : null;
      const d = containDestRect(image.naturalWidth || image.width, image.naturalHeight || image.height, r.w, r.h, align);
      drawAdjusted(ctx, image, adjustments, d.x, d.y, d.w, d.h);
    }
  };
  ctx.save();
  ctx.translate(r.x + r.w / 2, r.y + r.h / 2);
  if (deg) ctx.rotate(deg * Math.PI / 180);
  draw();
  ctx.restore();
}

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.__apH1Guide) 그 유형으로 에디터가 열린다. 없으면 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/B/C/D/E/패션) 결과 미리보기를 패션 누끼 크기로 통일. APResultCardH1·각 스테이지가 이 값을 prop으로 받는다(하드코딩 금지).
// 레이아웃 세부는 추후 전 유형 일괄 정비 예정 → 여기선 A와 같은 크기만 맞춘다.
const RESULT_DISP = 460;
const H1_LOGO_MAX_COUNT = 3;
const H1_LOGO_MAX_ROW = 388;
const H1_LOGO_GAP = 10;
const H1_LOGO_RIGHT = 756; // 776 - 우측여백 20
function h1LogoAspect(value) {
  const n = Number(value);
  return Number.isFinite(n) && n > 0 ? Math.max(0.25, Math.min(20, n)) : 1;
}
function h1LogoScale(value) {
  const n = Number(value);
  return Number.isFinite(n) && n > 0 ? Math.max(0.25, Math.min(4, n)) : 1;
}
function h1LogoScaleW(g) {
  const n = Number(g && g.scaleW);
  if (Number.isFinite(n) && n > 0) return Math.max(0.25, Math.min(4, n));
  return h1LogoScale(g && g.scale);
}
function h1LogoScaleH(g) {
  const n = Number(g && g.scaleH);
  if (Number.isFinite(n) && n > 0) return Math.max(0.25, Math.min(4, n));
  return h1LogoScale(g && g.scale);
}
function h1LogoRow(logos, heightFor, maxRow) {
  const usePerLogoScale = !heightFor;
  const arr = (logos || []).slice(0, H1_LOGO_MAX_COUNT).map((g) => {
    const aspect = h1LogoAspect(g.aspect);
    const baseH = heightFor ? heightFor(g) : (g.orient === 'h' ? 20 : 60);
    return { url: g.url, baseH, baseW: baseH * aspect, aspect };
  });
  if (!arr.length) return { items: [], rowH: 0, gap: H1_LOGO_GAP, rowW: 0 };
  const totalW = arr.reduce((s, l) => s + l.baseW, 0) + H1_LOGO_GAP * (arr.length - 1);
  const limit = Number.isFinite(Number(maxRow)) ? Number(maxRow) : H1_LOGO_MAX_ROW;
  const k = totalW > limit ? limit / totalW : 1;
  const gap = H1_LOGO_GAP * k;
  const rightEdges = new Array(arr.length);
  let xR = H1_LOGO_RIGHT;
  for (let i = arr.length - 1; i >= 0; i--) {
    rightEdges[i] = xR;
    xR = xR - arr[i].baseW * k - gap;
  }
  const minX = H1_LOGO_RIGHT - limit;
  const logoList = (logos || []).slice(0, H1_LOGO_MAX_COUNT);
  const items = arr.map((l, i) => {
    const bw = l.baseW * k, bh = l.baseH * k;
    if (!usePerLogoScale) return { url: l.url, w: bw, h: bh, baseW: bw, baseH: bh, rightEdge: rightEdges[i] };
    const g = logoList[i] || {};
    let w = bw * h1LogoScaleW(g);
    const h = bh * h1LogoScaleH(g);
    const maxW = rightEdges[i] - minX;
    if (w > maxW && maxW > 0) w = maxW;
    if (w < 1) w = 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: totalW * k,
  };
}

// 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 value = raw == null ? '' : String(raw).trim();
  return value && !PROJECT_CATEGORY_LABELS.has(value) ? value : '';
}
// 7버킷 기반 엔진 룰 — GNB 루트→버킷 변환(ROOT_TO_COMMERCE)을 거쳐 catRule()로 조회.
const CAT_RULES = {
  '패션': { prodMax: 8, modelMax: 5, modelCentric: true },
  '패션잡화': { prodMax: 10, 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;
}
// bannerType(A/B/C/D/E) → H1GuidePatterns layoutId 변환 (SSOT: engine/h1-guide-patterns.js)
function layoutIdFor(btype) {
  if (btype === 'B') return 'split-nukki';
  if (btype === 'C') return 'split';
  if (btype === 'D') return 'full';
  if (btype === 'E') return 'insurance';
  return 'nukki'; // A
}
// 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 isCatalogFashionAccessory(record) {
  if (!record) return false;
  const path = Array.isArray(record.categoryPath) ? record.categoryPath.join('>') : '';
  const text = [record.categoryRoot, record.category, 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) {
  // Catalog 패션잡화(신발/가방 등)는 상품 메타가 이미 확정된 경로이므로 MP 인물 오탐보다 우선한다.
  if (isCatalogFashionAccessory(record)) return null;
  const hasMp = !!window.__mpClassify;
  // 로컬(MediaPipe)로 상품/모델 먼저 판별 — 사람이면 '모델'(LLM 불필요). 1E 자동배치와 동일한 파이프라인.
  if (hasMp) { try { const l = await window.__mpClassify(src); if (l === '모델') return '모델'; } catch (e) {} }
  // 어시스턴트·카탈로그 메타(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이 '모델'로 판정하면 로컬 MP 결과에 상관없이 신뢰 (사람이 있는 이미지는 항상 모델).
  // mp가 상품으로 잘못 분류해도 LLM 모델 감지가 우선 — 특히 의류를 입은 사람(패션잡화로 오인되는 경우).
  return TCAT_LABELS.includes(label) ? label : label || null;
}
const isModelLabel = (l) => l === '모델';
const H1_HEAD_VISION_WAIT_MS = 2500;
let h1HeadVisionReady = false;
let h1HeadVisionDeferred = false;
async function canUseModelHeadVision() {
  if (!window.__mpHead) return false;
  if (!window.__ensureVision || window.__mpPose || h1HeadVisionReady) return true;
  if (h1HeadVisionDeferred) return false;
  let init;
  try {init = window.__ensureVision();} catch (e) {return false;}
  const ready = await Promise.race([
    Promise.resolve(init).then(() => true).catch(() => false),
    new Promise((resolve) => setTimeout(() => resolve(false), H1_HEAD_VISION_WAIT_MS)),
  ]);
  if (ready) {h1HeadVisionReady = true;return true;}
  h1HeadVisionDeferred = true;
  Promise.resolve(init).then(() => {h1HeadVisionReady = true;h1HeadVisionDeferred = false;}).catch(() => {h1HeadVisionDeferred = false;});
  return false;
}
async function analyzeModelHead(src) {
  if (await canUseModelHeadVision()) { 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 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>);
}
// [H1] 전역 배지(AddTextBadge/DecoBadge)는 nukki550이 이미 노출 → 재노출 안 함(동일 함수, 충돌 방지)

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 : {};
  return {
    x: clampPct(p.x != null ? Number(p.x) || 50 : 50),
    y: clampPct(p.y != null ? Number(p.y) || 50 : 50),
    zoom: +clampAddImgZoom(p.zoom != null ? p.zoom : 1).toFixed(2),
  };
};
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).
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;
    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 };
    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 };
    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;
    const cx = cc.getContext('2d');setHighQualityImageSmoothing(cx);cx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
    return { url: cc.toDataURL('image/jpeg', 1.0), 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 + 새 이미지 리셋에서 공용). H1 자체 기본값(가로/세로 겹침 0.1) 보존.
const DEFAULT_BG_MODE = 'png';
const DEFAULT_SCORER_STATE = { targetDensity: 1, overlapTol: 0.1, verticalOverlapTol: 0.1, 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: 50, w: 676, h: 288 };
    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 centerCandidateRectsInBounds(cand, bounds) {
  const rects = cand && Array.isArray(cand.rects) ? cand.rects : [];
  if (!bounds || rects.length < 2) return cand;
  const top = Math.min.apply(null, rects.map((r) => r.y));
  const bottom = Math.max.apply(null, rects.map((r) => r.y + r.h));
  const clusterH = bottom - top;
  if (!Number.isFinite(clusterH) || clusterH <= 0 || clusterH >= bounds.h) return cand;
  const targetTop = bounds.y + (bounds.h - clusterH) / 2;
  const dy = Math.max(bounds.y - top, Math.min(bounds.y + bounds.h - bottom, targetTop - top));
  if (Math.abs(dy) < 0.5) return cand;
  return { ...cand, rects: rects.map((r) => ({ ...r, y: Math.round((r.y + dy) * 10) / 10 })) };
}
function buildAccessoryEvenGridCandidate(items, bounds) {
  const list = (items || []).filter(Boolean);
  if (!bounds || !/^\d+$/.test(String(list.length)) || list.length < 4) return null;
  const n = list.length;
  const cols = n <= 4 ? 2 : n <= 6 ? 3 : 4;
  const rows = [];
  for (let i = 0; i < n; i += cols) rows.push(list.slice(i, i + cols));
  const gap = Math.min(10, Math.max(6, bounds.w * 0.025));
  const rowGap = Math.min(12, Math.max(8, bounds.h * 0.035));
  const rowAspectSums = rows.map((row) => row.reduce((sum, it) => sum + Math.max(0.15, Number(it.aspect) || 1), 0));
  const hByWidth = Math.min.apply(null, rows.map((row, ri) => (bounds.w - gap * Math.max(0, row.length - 1)) / Math.max(1, rowAspectSums[ri])));
  const h = Math.max(24, Math.min((bounds.h - rowGap * Math.max(0, rows.length - 1)) / rows.length, hByWidth));
  const totalH = h * rows.length + rowGap * Math.max(0, rows.length - 1);
  let y = bounds.y + (bounds.h - totalH) / 2;
  const rects = [];
  rows.forEach((row, ri) => {
    const rowW = row.reduce((sum, it) => sum + h * Math.max(0.15, 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.15, Number(it.aspect) || 1);
      const w = h * aspect;
      rects.push({
        id: it.id != null ? it.id : 'bn' + (ri * cols + 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: 'accessory-even-grid-' + n + '-' + Math.round(bounds.w),
    strategy: 'accessory-even-grid',
    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: 'accessory-even-grid|' + n + '|' + rows.map((row) => row.length).join('.'),
  };
}
function AutoPlaceTyped(props) {
  // 배너 만들기에서 고른 가이드(유형)를 받으면 그 유형으로 시작. 없으면 A(기존 동작 그대로).
  const ga4Src = (props && props.ga4Source) === 'asst' ? 'asst' : 'edit';
  const guide = props && props.guide || typeof window !== 'undefined' && window.__apH1Guide || 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 [placementArmed, setPlacementArmed] = useS2(() => !!(props && props.restore) || (Array.isArray(props && props.initialImages) && props.initialImages.length > 0));
  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, crownFrac, faceHFrac, 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);
  const [editRects, setEditRects] = useS2({}); // {candId: rects} — 제품 오브젝트 편집 상태
  const preserveSrcChangeRef = useSR2(false);
  const pendingSourceEditsRef = useSR2(null);
  const [selectedId, setSelectedId] = useS2(null);
  const [selectedIds, setSelectedIds] = useS2([]);
  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);
  // 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 [bSplit, setBSplit] = useS2(null); // B 분할누끼 화보 패널 분할점(null=기본 517, 조정박스 드래그로 변경)
  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); // 로고 대표색에서 뽑은 연한 톤 hex
  const [eTextColor, setETextColor] = useS2(null); // 보험명 텍스트 색 = 로고 대표색 (가독성 위해 밝으면 어둡게)
  const [eFocal, setEFocal] = useS2(null); // 모델 초점
  // 선택사항(우측)
  const [logoSrcs, setLogoSrcs] = useS2([]); // 로고 (독립, 최대 3)
  const [logoMeta, setLogoMeta] = useS2([]); // 로고 메타(비율·가로/세로형) — 캔버스 오버레이 & 예약 높이
  const [logoOrientOverrides, setLogoOrientOverrides] = useS2({}); // 로고별 수동 유형 선택: {src:'h'|'v'}
  const pendingLogoScalesRef = useSR2(null);
  const pendingLogoScaleWsRef = useSR2(null);
  const pendingLogoScaleHsRef = useSR2(null);
  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 [decoSelected, setDecoSelected] = useS2(false);
  const [decoScale, setDecoScale] = useS2(1);
  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 [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),catBoxRef = useSR2(null);
  const catCacheRef = useSR2({});
  const pendingRef = useSR2(null),undoRef = useSR2(null),redoRef = useSR2(null);
  const chipImgRef = useSR2(null);
  // 카탈로그 불러오기: 원본 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 carryModelAnalysis = (replacements) => {
    const pairs = (replacements || []).filter((pair) => pair && pair.oldSrc && pair.nextSrc);
    if (!pairs.length) return;
    pairs.forEach(({ oldSrc, nextSrc }) => {
      if (Object.prototype.hasOwnProperty.call(catCacheRef.current, oldSrc)) catCacheRef.current[nextSrc] = catCacheRef.current[oldSrc];
      if (Object.prototype.hasOwnProperty.call(faceCacheRef.current, oldSrc)) faceCacheRef.current[nextSrc] = faceCacheRef.current[oldSrc];
    });
    setFaceData((prev) => {
      let changed = false; const next = { ...prev };
      pairs.forEach(({ oldSrc, nextSrc }) => {if (Object.prototype.hasOwnProperty.call(prev, oldSrc)) {next[nextSrc] = prev[oldSrc];changed = true;}});
      return changed ? next : prev;
    });
    setHeadChinData((prev) => {
      let changed = false; const next = { ...prev };
      pairs.forEach(({ oldSrc, nextSrc }) => {if (Object.prototype.hasOwnProperty.call(prev, oldSrc)) {next[nextSrc] = prev[oldSrc];changed = true;}});
      return changed ? next : prev;
    });
  };
  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) {setPlacementArmed(false);setSrcs((p) => [...p, ...u]);}};
  const armPlacement = () => {if (!srcs.length) {showToast('이미지를 먼저 선택해 주세요');return;}setPlacementArmed(true);};
  // 카탈로그 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;
    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);
        setSrcs((prev) => {
          if (prev.length >= maxLen) { showToast(`이미지는 최대 ${maxLen}장까지 추가할 수 있어요`); return prev; }
          setPlacementArmed(false);
          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 openNukkiPopup = () => {
    if (!(window.NukkiPopup && window.NukkiPopup.open)) {showToast('누끼 도구를 불러오지 못했어요 (스크립트 로드 확인)');return;}
    const selectedUploads = nukkiSel.filter((src) => srcs.includes(src));
    const imagesToSend = imgTab === 'upload'
      ? (selectedUploads.length ? selectedUploads : srcs.slice())
      : srcs.slice();
    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);
        carryModelAnalysis(replacements);
        if (editCand && replacements.length) {
          pendingSourceEditsRef.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 logoAutoOrient = (aspect) => aspect >= 1.6 ? 'h' : 'v';
  const setLogoOrient = (src, orient) => {preserveCurrentPlacementForOverlay();setLogoOrientOverrides((p) => ({ ...p, [src]: orient }));setLogoMeta((prev) => prev.map((g) => g.url === src ? { ...g, orient } : g));};
  const setLogoSizeH1 = (idx, axis, value) => {
    preserveCurrentPlacementForOverlay();
    setLogoMeta((prev) => prev.map((g, i) => {
      if (i !== idx) return g;
      const v = h1LogoScale(value);
      return axis === 'h' ? { ...g, scaleH: v } : { ...g, scaleW: v };
    }));
  };
  const removeLogoAt = (idx) => {
    const removed = logoSrcs[idx];
    preserveCurrentPlacementForOverlay();
    setLogoSrcs((p) => p.filter((_, k) => k !== idx));
    if (removed) setLogoOrientOverrides((m) => { const n = { ...m }; delete n[removed]; return n; });
  };
  const addLogos = async (files) => {const u = await filesToUrls(files);if (u.length) {preserveCurrentPlacementForOverlay();setLogoSrcs((p) => [...p, ...u].slice(0, H1_LOGO_MAX_COUNT));}};
  const applyLibraryModels = (assets) => {
    const modelSrcs = (assets || []).map((asset) => asset && asset.src).filter(Boolean);
    modelSrcs.forEach((src) => {catCacheRef.current[src] = '모델';});
    if (modelSrcs.length) setPlacementArmed(false);
    setSrcs((prev) => Array.from(new Set(prev.concat(modelSrcs))).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, H1_LOGO_MAX_COUNT));};
  const setAddImg = async (files) => {const u = await filesToUrls(files);if (!u.length) return;setAddImgSrc(u[0]);setAddImgPos({ x: 50, y: 50 });setAddImgKind(await detectImageKind(u[0]));};
  const clearAddImg = () => {setAddImgSrc(null);setAddImgKind(null);setAddImgPos({ x: 50, y: 50 });};
  const labelSourceAsModel = async (src) => {
    if (!src) return false;
    if (isCatalogFashionAccessory(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 addSourceKeys = (keys, value) => { if (value) keys.add(value); };
  const itemMatchesSources = (item, keys) => !!item && (keys.has(item.src) || keys.has(item.url));
  const rectMatchesSources = (rect, keys) => !!rect && (keys.has(rect.src) || keys.has(rect.url));
  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 preserveCandidate = (cand, rects) => {
    if (!cand) return;
    const id = String(cand.id || 'layout') + '-preserved-' + rects.length;
    let rowsLabel = String(cand.rowsLabel || '').replace(/(?: · 배치 유지)+$/, '');
    let metrics = { ...(cand.metrics || {}) };
    if (window.AP_H1 && window.AP_H1.computeMetrics) metrics = { ...metrics, ...window.AP_H1.computeMetrics(rects, cand.bounds) };
    if (cand.strategy === 'fashion' || metrics.fashion) {
      const modelCount = rects.filter((rect) => rect && rect.role === 'model').length;
      rowsLabel = rowsLabel.replace(/^\d+인/, modelCount + '인');
      metrics = { ...metrics, fashion: true, models: modelCount };
    }
    const next = { ...cand, id, rects, metrics, _preservedLayout: layoutType, strategyLabel: '현재 배치 유지', rowsLabel: rowsLabel + ' · 배치 유지' };
    setRestoredCand(next);
    setEditRects((prev) => ({ ...prev, [id]: rects }));
    if (layoutType === 'B') setBPinned(id); else setPinnedId(id);
  };
  const removeSourcesPreservePlacement = (targets) => {
    const keys = sourceKeys(targets);
    if (!keys.size) return;
    if (editCand) {
      const before = rectsFor(editCand);
      const after = before.filter((rect) => !rectMatchesSources(rect, keys));
      if (after.length !== before.length) preserveCandidate(editCand, after);
    }
    preserveSrcChangeRef.current = true;
    setSrcs((prev) => prev.filter((src) => !keys.has(src)));
    setNukkiSel((prev) => prev.filter((src) => !keys.has(src)));
    setAllItems((prev) => prev.filter((item) => !itemMatchesSources(item, 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([]);
    setFocalHist([]);setFocalRedo([]);
    setSelectedId(null);
  };
  const removeSrc = (i) => { const target = srcs[i]; if (target) removeSourcesPreservePlacement([target]); };
  const pickPinned = (id) => {setSelectedId(null);setSelectedIds([]);setPinnedId(id);};
  const pickBPinned = (id) => {setSelectedId(null);setSelectedIds([]);setBPinned(id);setBFocal(null);};
  // 편집 후보에서는 rect만 제거해 실행취소/초기화를 보존하고, 후보 밖에서는 원본 src를 제거한다.
  // rects.url은 처리된 dataURL이므로 직접 비교 불가. id(itN/bnN)로 allItems/bNukki 역참조.
  const onCanvasDeleteItem = (r) => {
    if (editCand && r && r.id != null) {
      preserveCurrentPlacementForOverlay();
      const cur = rectsFor(editCand);
      const next = cur.filter((it) => it.id !== r.id);
      setHistory((h) => [...h.slice(-60), { id: editCand.id, rects: cur }]);
      setRedo([]);
      setEditRects((p) => ({ ...p, [editCand.id]: next }));
      setSelectedId(null);
      return;
    }
    if (r.src && srcs.includes(r.src)) { removeSourcesPreservePlacement([r.src]); return; }
    // 후보가 재생성되면 id는 현재 목록의 라벨(itN/bnN)로 다시 매겨진다.
    // 배열 위치를 가정하지 않고 현재 준비 목록에서 먼저 역참조해 삭제한다.
    const byId = allItems.find((it) => it.id === r.id) || bNukki.find((it) => it.id === r.id);
    if (byId && byId.src) { removeSourcesPreservePlacement([byId.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 = () => {setPlacementArmed(false);setSrcs([]);setNukkiSel([]);setAllItems([]);setCategory(null);setCatAuto(true);setPinnedId(null);setBPinned(null);setSplitPinned(null);setFullPinned(null);setPatLock(null);setEditRects({});setSelectedId(null);setHistory([]);setRedo([]);setBFocal(null);setBSplit(null);setBPhotoSrc(null);setSplitFocals({});setFullFocals({});setLogoSrcs([]);setLogoOrientOverrides({});setExSel(null);setAddText('');setAddImgSrc(null);setMarkType(null);setAddImgType('사은품');setAddImgTitle('사은품');setChipColors([]);setChipHexInput('');setFlagCount(1);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;
    });
    // 부가정보 종류를 바꾸면 H1 가이드 우상단 기본 위치부터 다시 시작한다.
    setDecoPos(null);
  };
  const setTextPlacement = (mode) => {
    if (mode === 'center' && deco && window.H1DecoPlacement) setDecoPos(window.H1DecoPlacement.centerPosition(deco));
    else setDecoPos(null);
  };
  useSE2(() => {
    if (exSel === 'mark' || (exSel === 'flag' && !isDigitalCategoryForFlag()) || (exSel && bannerType !== 'A' && bannerType !== 'D')) { setExSel(null); setDecoSelected(false); }
  }, [bannerType, category, exSel]);
  const addChipHex = () => {const h = normHex(chipHexInput);if (!h || chipColors.length >= CHIP_MAX) return;setChipColors((p) => [...p, h]);setChipHexInput('');};
  const addChipFromImage = async (files) => {const u = await filesToUrls(files);if (!u.length || chipColors.length >= CHIP_MAX) return;setChipBusy(true);try {const hex = await extractDominantColor(u[0]);setChipColors((p) => p.length < CHIP_MAX ? [...p, hex] : p);} catch (e) {showToast('대표색 추출에 실패했어요');} finally {setChipBusy(false);}};
  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 () => {
      if (!placementArmed) {setAllItems([]);setBusy(false);return;}
      // 화보/분할 유형(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 cm = typeof window !== 'undefined' && window.__catalogMeta;
        const cc = typeof window !== 'undefined' && window.__commerceCategory;
        const prepared = await Promise.all(srcs.map(async (src, i) => {
          let it = { ...(await window.AP_H1.prepareItem(src, bgMode)), id: 'it' + i, src };
          const rec = recordForSource(src);
          if (cm && rec) it = cm.applyToItem(it, rec, cc);
          return 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, placementArmed]);

  // 화보/보험 유형(D 풀이미지·C 분할·E 보험): 업로드 이미지를 원본 그대로 로드(비율만 측정). 누끼 파이프라인과 분리.
  useSE2(() => {
    if (!placementArmed) {setFullItems([]);return;}
    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, placementArmed]);

  // [보험] 인물 누끼 자동 인식 — 인물 점유율(프레임을 얼마나 채우나) 기준. 순서 무관.
  //  · 단일 이미지는 무조건 배경(인물 자동배치는 배경+인물 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 (!placementArmed) {setBNukki([]);setBPhoto(null);setBBusy(false);return;}
    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) {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) => {
        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)) {
          // 인물누끼 → 화보 없으면 우 패널로 사용, 있으면 전체 제외(좌 클러스터 금지)
          if (!photo) {
            try {const im = await new Promise((res, rej) => {const i = new Image();i.onload = () => res(i);i.onerror = rej;i.src = s;});photo = { src: s, url: s, aspect: im.naturalWidth / Math.max(1, im.naturalHeight) };} catch (e) {}
          }
          continue;
        }
        try {const it = await window.AP_H1.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, placementArmed]);

  // 자동 유형 라우팅(업로드/불러오기 공통 · 업로드 바뀔 때 재평가. 이후 사용자 수동선택은 다음 업로드 전까지 존중):
  //  · 화보(사진)+누끼(컷) 공존 → 분할+누끼(B)
  //  · 화보만 → 2장 이상 분할형(C) / 1장 풀이미지(D)   [H1 가이드: 화보는 분할/풀이미지, 누끼 처리 금지]
  //  · 누끼만 → 누끼형(A)
  //  판별=detectImageKind(투명도 기준: photo=화보 / nukki=컷).
  useSE2(() => {
    if (!placementArmed) return;
    if (bannerType === 'E' || manualTypeRef.current) return;                 // 보험 또는 사용자 수동 선택은 자동 유형 라우팅으로 덮지 않음
    if (!srcs.length) return;
    let cancelled = false;
    (async () => {
      let photoN = 0, nukkiN = 0;
      for (const s of srcs) {
        let kind = 'photo';
        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); }    // 화보만 → 분할형(2+) / 풀이미지(1)
      else if (nukkiN && !photoN) { setBannerType('A'); setLayoutType('A'); }                        // 누끼만 → 누끼형
    })();
    return () => {cancelled = true;};
  }, [srcs, bannerType, placementArmed]);
  // 이미지 세트가 바뀌면 다음 자동 판별을 허용. 복원 중(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 () => {
      const measured = await Promise.all(missing.map(async (it) => {
        let geo = null;
        try {geo = await analyzeModelHead(it.url || it.src);} catch (e) {geo = null;}
        const data = { topFrac: geo && geo.topFrac, faceHFrac: geo && geo.hsFrac, person: null };
        return { src: it.src, data };
      }));
      if (cancelled) return;
      measured.forEach(({ src, data }) => {faceCacheRef.current[src] = data;});
      setFaceData((prev) => {const next = { ...prev };measured.forEach(({ src, data }) => {next[src] = data;});return next;});
      // 동일 인물 그룹화는 후보 배열 보정용이며 기본 배치의 필수값은 아니다.
      // 원격 식별을 기다리느라 4·5인 렌더를 막지 않고, 완료되면 personKey만 백그라운드에서 보강한다.
      measured.forEach(({ src, data }) => {
        detectFaceMeta(src).then((meta) => {
          if (cancelled || !(meta && meta.person)) return;
          const enriched = { ...data, person: meta.person };
          faceCacheRef.current[src] = enriched;
          setFaceData((prev) => ({ ...prev, [src]: enriched }));
        }).catch(() => {});
      });
    })();
    return () => {cancelled = true;};
  }, [allItems]);

  // [task-011 Phase1] 화보(사진) 피사체 검출 — 분할(C)/풀이미지(D)에서 각 사진의 얼굴 중심·크기·정수리를 원본 프레임 기준으로 감지.
  //   __mpHead(src,{raw:true})로 배경제거 없이 원본에서 검출(자르면 x가 틀어짐). 미검출/사람없음 → null(중앙 크롭 폴백).
  //   [정수리 실측] __mpHead의 crownFrac은 관절 추정치(두개골 상단)라 머리카락을 포함하지 않아 실제보다 9~14px 아래를 가리킨다.
  //   → window.__photoCrown 으로 픽셀에서 머리카락 끝을 찾아 대체. 배경이 복잡해 실측 실패한 사진만 추정치 유지(crownMeasured=false).
  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; }
        let mc = null;
        if (r && window.__photoCrown) { try { mc = await window.__photoCrown(it.src, { midXFrac: r.midXFrac, faceHFrac: r.faceHFrac, eyeFrac: r.eyeFrac }); } catch (e) { mc = null; } }
        const est = r && (r.crownFrac != null ? r.crownFrac : r.topFrac);
        photoFaceCacheRef.current[it.src] = r ? { midXFrac: r.midXFrac, bodyMidXFrac: r.bodyMidXFrac, crownFrac: (mc && mc.confident) ? mc.crownFrac : est, crownMeasured: !!(mc && mc.confident), 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 }.
  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]);

  // 개수 제한 (감지 후 적용) — 상품/모델 각각. H1GuidePatterns 패턴 상한 우선, 없으면 catRule 폴백.
  useSE2(() => {
    if (!category) return;
    const pCount = productItems.length, mCount = modelItems.length;
    // 패턴 단일 출처(H1GuidePatterns) 상한 사용. 없으면 catRule 폴백.
    const GP = typeof window !== 'undefined' && window.H1GuidePatterns;
    const patternResult = GP ? GP.resolvePattern({
      category,
      layoutId: layoutIdFor(bannerType),
      prodCount: pCount,
      modelCount: mCount,
      photoCount: bannerType === 'C' ? fullItems.length : bannerType === 'B' ? (bPhoto ? 1 : 0) : 0,
    }) : null;
    const effProdMax = (patternResult && patternResult.pattern && patternResult.pattern.prodMax > 0)
      ? patternResult.pattern.prodMax
      : rule.prodMax;
    const effModelMax = (patternResult && patternResult.pattern && patternResult.pattern.modelMax > 0)
      ? patternResult.pattern.modelMax
      : rule.modelMax;
    if (pCount > effProdMax || mCount > effModelMax) {
      let pSeen = 0, mSeen = 0;
      const keep = allItems.filter((it) => {
        if (isModelLabel(it.label)) { mSeen++; return mSeen <= effModelMax; }
        pSeen++; return pSeen <= effProdMax;
      }).map((it) => it.src);
      setSrcs((p) => p.filter((s) => keep.includes(s)));
      showToast(`${category} 가이드 적용 — 상품 최대 ${effProdMax} · 모델 최대 ${effModelMax}`);
    }
  }, [category, allItems, bannerType, fullItems.length, bPhoto]);

  // 카테고리 드롭다운: 바깥 클릭 시 닫기
  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; });
        const pendingWs = pendingLogoScaleWsRef.current || pendingLogoScalesRef.current;
        const pendingHs = pendingLogoScaleHsRef.current || pendingLogoScalesRef.current;
        const next = arr.map((g, i) => {
          const prevG = byUrl[g.url];
          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 { ...g, orient: logoOrientOverrides[g.url] || (prevG && prevG.orient) || logoAutoOrient(g.aspect), scaleW, scaleH };
        });
        pendingLogoScalesRef.current = null;
        pendingLogoScaleWsRef.current = null;
        pendingLogoScaleHsRef.current = null;
        return next;
      });
    });
    return () => {cancelled = true;};
  }, [logoSrcs, logoOrientOverrides]);
  // 로고 가이드: 우측 상단 로고 높이만큼(+여백) 콘텐츠 상단을 예약 → 제품/모델과 겹침 방지
  // 로고 가이드 + 부가텍스트 고정위치: 우측 상단 데코 영역(로고 → 아래 30px → 텍스트 배지 90)만큼 상단을 예약
  const logoH = useSM2(() => h1LogoRow(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 (bannerType === 'E') return null; // [H1] 보험은 부가정보 없음 — 다른 유형에서 고른 exSel이 남아 렌더에 딸려오는 것 차단
    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 });
    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;
  }, [bannerType, exSel, addText, addImgType, addImgTitle, addImgSrc, flagCount, chipColors, addImgKind, addImgPos, markType, productItems.length, decoScale]);
  const h1DecoDefaultPos = useSM2(() => {
    if (!deco || !window.H1DecoPlacement) return null;
    return window.H1DecoPlacement.defaultPosition(deco, logoMeta.length ? 20 + logoH : null);
  }, [deco, logoMeta.length, logoH]);
  const h1DecoCenterPos = useSM2(() => deco && window.H1DecoPlacement ? window.H1DecoPlacement.centerPosition(deco) : null, [deco]);
  const h1DecoCurrentPos = useSM2(() => {
    if (!deco || !window.H1DecoPlacement) return null;
    return window.H1DecoPlacement.clamp(decoPos && typeof decoPos === 'object' ? decoPos : h1DecoDefaultPos, deco);
  }, [deco, decoPos, h1DecoDefaultPos]);
  const decoPlacementMode = h1DecoCurrentPos && h1DecoCenterPos && h1DecoCurrentPos.x === h1DecoCenterPos.x && h1DecoCurrentPos.y === h1DecoCenterPos.y
    ? 'center' : !decoPos ? 'default' : 'custom';
  const setDecoCoord = (axis, raw) => {
    if (!deco || !window.H1DecoPlacement) return;
    const value = Number(raw);
    if (!Number.isFinite(value)) return;
    const base = h1DecoCurrentPos || h1DecoDefaultPos || { x: 0, y: 0 };
    setDecoPos(window.H1DecoPlacement.clamp({ ...base, [axis]: value }, deco));
  };
  const logoReserveTop = useSM2(() => logoH ? logoH + 14 : 0, [logoH]); // 로고만 예약(겹침 금지). 부가정보(이미지 표기 제외)는 모두 상품과 겹침 허용 → 예약 제외
  // H1 가이드 패턴NO (SSOT: engine/h1-guide-patterns.js) — 카테고리 + 유형 + 이미지 조합으로 배정.
  // 여행 버킷은 B 탭 숨김(shouldHideB). 패턴 상한 초과 시 truncated 플래그.
  const currentPattern = useSM2(() => {
    const GP = typeof window !== 'undefined' && window.H1GuidePatterns;
    if (!GP || !category) return null;
    const prodCount = bannerType === 'B' ? bNukki.length : bannerType === 'A' ? productItems.length : 0;
    const modelCount = bannerType === 'A' ? modelItems.length : 0;
    const photoCount = bannerType === 'C' ? fullItems.length : bannerType === 'B' ? (bPhoto ? 1 : 0) : bannerType === 'D' ? (fullItems.length ? 1 : 0) : 0;
    return GP.resolvePattern({
      category,
      layoutId: layoutIdFor(bannerType),
      prodCount,
      modelCount,
      photoCount,
    });
  }, [category, bannerType, productItems.length, modelItems.length, fullItems.length, bNukki.length, bPhoto]);
  const h1MixedMultiModelPattern = useSM2(() => {
    const total = productItems.length + modelItems.length;
    if (layoutType !== 'A' || !productItems.length || modelItems.length <= 1 || total > 5) return false;
    const GP = typeof window !== 'undefined' && window.H1GuidePatterns;
    const bucket = GP && category ? GP.bucketOf(category) : null;
    return bucket === 'fashion' || /패션|의류|언더웨어|스포츠/.test(String(category || ''));
  }, [layoutType, category, productItems.length, modelItems.length]);
  const h1NukkiPatternItems = useSM2(() => h1MixedMultiModelPattern ? allItems.slice() : productItems, [h1MixedMultiModelPattern, allItems, productItems]);
  // 여행 버킷 B탭 숨김 여부
  const hideBTab = useSM2(() => {
    const GP = typeof window !== 'undefined' && window.H1GuidePatterns;
    return GP ? GP.shouldHideB(category) : false;
  }, [category]);
  const candidates = useSM2(() => {
    if (!(layoutType === 'A' && h1NukkiPatternItems.length)) return [];
    const sc = { ...scorer, logoReserveTop, category };
    const base = window.AP_H1.generateCandidates(h1NukkiPatternItems, h1MixedMultiModelPattern ? null : modelItem, sc) || [];
    // HARD RULE: 상품 누끼 ≥1 + 인물 누끼 ≥1 → 인물은 항상 우측. modelCorner 후보 생성.
    //   인물이 1명이든 여러 명이든 동일 규칙(첫 번째 인물이 우측 밴드/우하단 앵커).
    if (!h1MixedMultiModelPattern && modelItems.length >= 1 && window.AP_H1.generateModelCornerCandidates) {
      const corner = window.AP_H1.generateModelCornerCandidates(modelItems, productItems, { ...sc, category }) || [];
      return base.concat(corner);
    }
    return base;
  }, [layoutType, h1NukkiPatternItems, h1MixedMultiModelPattern, productItems, modelItems, modelItem, scorer, category]);
  const ranked = useSM2(() => {
    if (!candidates.length) return restoredCand && restoredCand._preservedLayout !== 'B' ? [restoredCand] : [];
    const base = window.AP_H1.rankCandidates ? window.AP_H1.rankCandidates(candidates, scorer) : candidates;
    const accessoryRanked = rankAccessoryCenteredCandidates(base, category, productItems.length, modelItems.length);
    if (!restoredCand || restoredCand._preservedLayout === 'B') return accessoryRanked;
    return [restoredCand].concat(accessoryRanked.filter((cand) => cand.id !== restoredCand.id));
  }, [candidates, scorer, restoredCand, category, 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.AP_H1.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] 풀이미지 인물 → 머리끝 상단 정렬(가로는 구도 유지=중앙). 비인물은 focal 없음 → 엔진 프리셋(중앙/상하/좌우) 그대로.
  //   fy = (crownFrac - TOP*shf)/(1-shf), coverCrop(fullSourceRect) 공식과 동일 유도. 세로 여유 없는(가로로 넓은) 사진은 자동으로 중앙.
  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 = 776, CH = 388, TOP = 0.08;
    const shf = CH / Math.max(CW / a, CH);                 // 줌1 source 높이 비율
    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)) } }; // 가로 구도유지(50) · 세로 머리끝
  }), [fullItems, photoFace]);
  const fullCands = useSM2(() => layoutType === 'D' && fullPhotos.length ? window.AP_H1.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 = 776;cv.height = 388;
    const ctx = cv.getContext('2d');setHighQualityImageSmoothing(ctx);ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 776, 388);
    const sr = window.AP_H1.fullSourceRect(im.naturalWidth, im.naturalHeight, focal);
    drawAdjusted(ctx, im, adjustmentFor(cand.src || cand.url), sr.sx, sr.sy, sr.sw, sr.sh, 0, 0, 776, 388);
    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));
  };
  // C 분할형 사진 준비. 공식 후보는 동일 폭 세로 열이며, 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;
    const accessoryProduct = isCatalogFashionAccessory(recordForSource(it.src || it.url));
    return {
      ...it,
      url: (crop && crop.url) || it.url || it.src,
      contentAspect: crop ? crop.aspect : (accessoryProduct ? (it.aspect || null) : null),
      fit: accessoryProduct ? 'contain' : it.fit,
      noCrop: accessoryProduct || !!it.noCrop,
      isModel: !accessoryProduct && !!(f && f.crownFrac != null), // 얼굴/정수리 감지 → 모델 패널(항상 최우측)
      ...(focal ? { focal } : {})
    };
  }), [fullItems, splitContentCrops, photoFace]);
  // [SPLIT-FIX] alignSplitFaces 제거 — 패널 높이 균등화(specH)로 피사체 크기를 맞추므로 zoom 강제 불필요.
  //   (H1에서는 headShort 안내도 함께 제거 — 새 레이아웃에서는 패널이 항상 specH에 맞춰지므로 불필요.)
  const splitCands = useSM2(() => layoutType === 'C' && splitPhotos.length >= 2 ? window.AP_H1.generateSplitCandidates(splitPhotos, {}) : [], [layoutType, splitPhotos]);
  const splitPrimaryId = splitPinned && splitCands.some((c) => c.id === splitPinned) ? splitPinned : splitCands[0] && splitCands[0].id;
  const splitPrimary = splitCands.find((c) => c.id === splitPrimaryId) || splitCands[0] || null;
  const splitSecondary = splitCands.find((c) => c.id !== (splitPrimary && splitPrimary.id)) || null;
  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] C 분할형 저장도 미리보기와 동일한 공식 동일 폭 패널을 사용한다.
  // [SPLIT-FIX] renderSplitBlob: pn.url = contentBboxCrop 결과(흰배경 제거된 콘텐츠 이미지).
  //   cover 렌더링 — 패널을 빈 여백 없이 채우고 focal로 크롭 위치를 조정한다.
  const renderSplitBlob = async (cand) => {
    if (!cand) return null;
    const cv = document.createElement('canvas');cv.width = 776;cv.height = 388;
    const ctx = cv.getContext('2d');setHighQualityImageSmoothing(ctx);ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 776, 388);
    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);
        if (pn.fit === 'cover' || (f.zoom || 1) > 1) {
          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);
        } else {
          const s = Math.min(pn.w / im.naturalWidth, pn.h / im.naturalHeight);
          const dw = im.naturalWidth * s, dh = im.naturalHeight * s;
          drawAdjusted(ctx, im, adjustmentFor(pn.src || pn.url), pn.x + (pn.w - dw) / 2, pn.y + (pn.h - dh) / 2, dw, dh);
        }
      } 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 분할+누끼형 후보 — 좌 2/3 영역에 A 누끼 클러스터(다양성 상속), 우 1/3 화보 패널.
  // ※ H1은 550(3/5)과 비율이 다르다. 가이드 자산 실측 0.671 ≈ 2/3 확인(2026-08-12). 550 주석을 복사해 3/5로 적혀 있던 것을 정정.
  // 분할 지점 하나(B_SPLIT)에서 좌 누끼 영역·우 화보 패널을 파생 → 비례 바꿀 땐 이 값 하나만 고치면 전부 동기화(하드코딩 흩어짐 제거).
  const B_SPLIT = bSplit != null ? bSplit : 517;          // 좌 누끼 : 우 화보 분할 X — 기본 2/3(517); 조정박스 NW/SW 핸들 드래그로 변경
  const B_RMARGIN = 50;                                   // 누끼 영역 오른쪽 여백 = 왼쪽 세이프(50)와 대칭(누끼만, 컨텐츠 전체 세이프는 불변)
  const B_LEFT = { x: 50, y: 50, w: B_SPLIT - 50 - B_RMARGIN, h: 288 }; // 좌측 누끼 영역(H1 388 - 세이프 50*2 = 288)
  const B_PHOTO = { x: B_SPLIT, y: 0, w: 776 - B_SPLIT, h: 388 };   // 우측 화보 패널(풀블리드)
  // B 분할누끼도 인물합성과 '똑같은' 배치(buildProdClusterGrid: A 클러스터 + B 격자 합침)를 좌측 영역에 적용 → 다른 종류는 클러스터, 같은 종류 ≤6은 1순위. (규칙 통일)
  const bCands = useSM2(() => {
    const spreadScorer = { ...scorer, category, overlapTol: Math.min(scorer.overlapTol == null ? 0.1 : scorer.overlapTol, 0.04), verticalOverlapTol: Math.min(scorer.verticalOverlapTol == null ? 0.1 : scorer.verticalOverlapTol, 0.04) };
    const base = layoutType === 'B' && bNukki.length ? window.AP_H1.buildProdClusterGrid(bNukki, spreadScorer, B_LEFT, window.AP_H1.isFashionCategory ? window.AP_H1.isFashionCategory(category) : /패션|의류|언더웨어|스포츠/.test(String(category || ''))) : [];
    let centered = bNukki.length >= 3 ? base.map((cand) => centerCandidateRectsInBounds(cand, B_LEFT)) : base;
    if (/패션잡화/.test(String(category || '')) && bNukki.length >= 4) {
      const even = buildAccessoryEvenGridCandidate(bNukki, B_LEFT);
      if (even) {
        const metrics = window.AP_H1.computeMetrics ? window.AP_H1.computeMetrics(even.rects, B_LEFT) : {};
        const scored = { ...even, metrics, scoring: { score: 999 } };
        centered = [scored].concat(centered.filter((cand) => cand.id !== scored.id));
      }
    }
    if (!restoredCand || restoredCand._preservedLayout !== 'B') return centered;
    return [restoredCand].concat(centered.filter((cand) => cand.id !== restoredCand.id));
  }, [layoutType, bNukki, scorer, category, B_SPLIT, 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 = () => bFocal || (bPhoto && bPhoto.focal) || { x: 50, y: 50 };
  const renderBBlob = async (cand, photo, focal) => {
    if (!cand) return null;
    const cv = document.createElement('canvas');cv.width = 776;cv.height = 388;
    const ctx = cv.getContext('2d');setHighQualityImageSmoothing(ctx);ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 776, 388);
    if (photo) {try {const im = await _loadImgD(photo.src || photo.url);const c = coverCrop(im.naturalWidth, im.naturalHeight, B_PHOTO.w, B_PHOTO.h, focal);drawAdjusted(ctx, im, adjustmentFor(photo.src || photo.url), c.sx, c.sy, c.sw, c.sh, B_PHOTO.x, B_PHOTO.y, B_PHOTO.w, B_PHOTO.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);drawRectAdjusted(ctx, im, adjustmentFor(r.src || r.url), r);} 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, bsp: bSplit, s: { ...splitFocals }, f: { ...fullFocals }, e: eFocal });
  const restoreFocals = (o) => {setBFocal(o.b);if (o.bsp !== undefined) setBSplit(o.bsp);setSplitFocals(o.s);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;});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 L = eLayout({ logoMeta, illustAspect: eIllust && eIllust.aspect, illustContain: eIllustContain, nameLines: String(eName || '').trim() ? 1 : 0, hasModel: !!eModel, modelAspect: eModel && eModel.aspect, deco });
    const cv = document.createElement('canvas');cv.width = 776;cv.height = 388;
    const ctx = cv.getContext('2d');setHighQualityImageSmoothing(ctx);ctx.fillStyle = eBg;ctx.fillRect(0, 0, 776, 388); // [보험 배경] 기본색 또는 컬러칩/커스텀
    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 _nm = String(eName || '').split(String.fromCharCode(10)).map((s) => s.trim()).filter(Boolean).join(' ');const lines = _nm ? [_nm] : []; // [H1] 보험명 줄바꿈 없이 단일 라인
    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는 좌하단(decoBox). 로고는 위에서 이미 그림.
    if (deco && L.decoBox && window.BannerOverlay) await window.BannerOverlay.drawDeco(ctx, deco, L.decoBox.x, L.decoBox.y, { 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 = 776;cv.height = 388;const ctx = cv.getContext('2d');setHighQualityImageSmoothing(ctx);ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 776, 388);const ordered = [...fashionCand.rects].sort((a, b) => a.z - b.z);for (const r of ordered) {try {const im = await _loadImgD(r.url);drawRectAdjusted(ctx, im, adjustmentFor(r.src || r.url), r);} 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.AP_H1.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;
    transferSourceMeta(oldSrc, nextSrc);
    const replacements = [{ oldSrc, oldKeys: Array.from(sourceKeys([oldSrc])), nextSrc }];
    if (editCand) pendingSourceEditsRef.current = { cand: { ...editCand }, rects: rectsFor(editCand).map((rect) => ({ ...rect })), replacements, 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);
    showToast('선택한 AI 배경 생성 시안을 적용했습니다');
  };
  const preserveCurrentPlacementForOverlay = () => {const cand = editCand;if (!cand) return;if (bannerType === 'B') {if (!bPinned) setBPinned(cand.id);return;}if (!pinnedId) setPinnedId(cand.id);};
  const setAddImgPosPreserved = (next) => {preserveCurrentPlacementForOverlay();setAddImgPos(normalizeRestoredAddImgPos(next));};
  const setDecoPosPreserved = (next) => {preserveCurrentPlacementForOverlay();setDecoPos(next);setDecoSelected(true);};
  const setDecoScalePreserved = (next) => {preserveCurrentPlacementForOverlay();setDecoScale(clampDecoScale(next));setDecoSelected(true);};
  const onSelectDeco = () => {if (!deco) return;setDecoSelected(true);setSelectedId(null);setSelectedIds([]);};
  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;const hasManual = !!(editRects[editCand.id] || (patLock && patLock.id === editCand.id));if (hasManual) {setHistory((h) => [...h.slice(-60), { id: editCand.id, rects: rectsFor(editCand) }]);setRedo([]);}setEditRects((p) => {const n = { ...p };delete n[editCand.id];return n;});setPatLock(null);setSelectedId(null);setDecoPos(null);setDecoSelected(false);setDecoScale(1);};
  // 배치 초기화 통합 핸들러 — 제품 rects(A/B) + 화보 초점(B/C/D/E) 모두 생성 시 상태로 복원
  const resetAll = () => {
    if (editCand) {
      const hasManual = !!(editRects[editCand.id] || (patLock && patLock.id === editCand.id));
      if (hasManual) {setHistory((h) => [...h.slice(-60), { id: editCand.id, rects: rectsFor(editCand) }]);setRedo([]);}
      setEditRects((p) => {const n = { ...p };delete n[editCand.id];return n;});
      setPatLock(null);setSelectedId(null);
    }
    setDecoPos(null);
    setDecoSelected(false);
    setDecoScale(1);
    const hasFocalManual = bannerType === 'B' ? (bFocal != null || bSplit != null)
      : bannerType === 'C' && splitPrimary ? Object.keys(splitFocals).some((k) => k.indexOf(splitPrimary.id + ':') === 0)
      : bannerType === 'D' && fullPrimary ? fullFocals[fullPrimary.id] != null
      : bannerType === 'E' ? eFocal != null : false;
    if (hasFocalManual) focalBegin();
    if (bannerType === 'B') { setBFocal(null); setBSplit(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;}); }
    else if (bannerType === 'D' && fullPrimary) { setFullFocals((p) => {const n = { ...p };delete n[fullPrimary.id];return n;}); }
    else if (bannerType === 'E') { setEFocal(null); }
  };
  const applyGuideRules = () => {
    const clearManual = () => {setScorer({ ...DEFAULT_SCORER_STATE });setEditRects({});setPatLock(null);setHistory([]);setRedo([]);setSelectedId(null);setFocalHist([]);setFocalRedo([]);setDecoPos(null);setDecoSelected(false);setDecoScale(1);};
    if (layoutType === 'B') {
      if (!bCands.length) { showToast('적용할 배치 후보가 없습니다'); return; }
      setBPinned(bCands[0].id);setBFocal(null);setBSplit(null);clearManual();showToast('가이드 규칙을 적용했습니다');return;
    }
    if (layoutType === 'C') {
      if (!splitCands.length) { showToast('적용할 배치 후보가 없습니다'); return; }
      setSplitPinned(splitCands[0].id);setSplitFocals({});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,
      guidePattern: currentPattern && currentPattern.pattern,
      itemCount: h1NukkiPatternItems.length,
      productCount: productItems.length,
      modelCount: modelItems.length,
    });
    if (!applied || !applied.pick) { showToast('가이드 규칙에 맞는 후보가 없습니다'); return; }
    setPinnedId(applied.pick.id);
    setRankTab(applied.pid || 'all');
    setPatLock(applied.rects ? { id: applied.pick.id, rects: applied.rects, pid: applied.pid } : null);
    clearManual();
    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 h1GuideBucket = window.GuideCompliance.h1Bucket(category);
  const h1CategoryCode = (window.SCHEMA_H1.categoryCodes || {})[h1GuideBucket] || '';
  const getGuideValidation = () => window.GuideCompliance.validate({
    guideId: 'h1', schema: window.SCHEMA_H1, outputCanvas: { w: 776, h: 388 }, enforceContentRules: true,
    category, layout: layoutType, logoCount: logoMeta.length,
    productCount: layoutType === 'B' ? bNukki.length : layoutType === 'A' ? productItems.length : 0,
    modelCount: layoutType === 'E' ? (eModel ? 1 : 0) : layoutType === 'A' ? modelItems.length : 0,
    photoCount: layoutType === 'C' ? (splitPrimary && splitPrimary.panels ? splitPrimary.panels.length : 0) : layoutType === 'B' ? (bPhoto ? 1 : 0) : layoutType === 'D' ? (fullPrimary ? 1 : 0) : 0,
    patternResult: currentPattern,
    splitBoundaryX: layoutType === 'B' ? B_SPLIT : null,
    splitPanels: layoutType === 'C' && splitPrimary ? splitPrimary.panels : null,
    insuranceName: eName, addon: exSel, addText, addImageCount: addImgSrc ? 1 : 0, flagCount,
    isDigitalCategory: /디지털|가전/.test(String(category || '')),
  });
  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 = 776;cv.height = 388;
    const ctx = cv.getContext('2d');setHighQualityImageSmoothing(ctx);ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 776, 388);
    const ordered = [...rects].sort((a, b) => a.z - b.z);
    for (const r of ordered) {try {const im = await _loadImgD(r.url);drawRectAdjusted(ctx, im, adjustmentFor(r.src || r.url), r);} 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_H1.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('h1', 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();
        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);
    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 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 || '';
  const collectSources = async () => {
    const out = [];
    for (const s of srcs) {try {const b = await (await fetch(s)).blob();if (b) out.push({ blob: b, subtype: 'upload' });} catch (e) {}}
    return out;
  };
  // 저장/복원 공용 모듈 사용(550과 동일 bag) — 로직 단일출처 window.BannerEditorPersist
  const _persistBag = () => ({
    projectCategory: '상품배너 H1', category: category, bannerType: bannerType,
    srcs, logoSrcs, logoMeta, addImgSrc, editCand, editRects,
    bFocal, bSplit, splitFocals, fullFocals, eFocal,
    exSel, addText, addImgKind, addImgPos, decoPos, decoScale, flagCount, chipColors, addImgType, addImgTitle, markType, logoOrientOverrides,
    scorer, bgMode, catAuto, pinnedId, imageAdjust, imageAdjustBySource, outpaintVariants,
    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: '상품배너 H1', type: _draftType(), event: forceSaved ? '저장 ' + _draftType() : '임시저장 ' + _draftType(), previewBlob: blob, width: 776, height: 388, 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;
        setAutoMsg((auto ? '자동 저장됨' : '저장됨') + ' · ' + window.BannerEditorPersist.nowTime());
        if (!auto) setSavedBtn(true);
        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 → 공용 로직으로 복원. 저장/복원 로직은 window.BannerEditorPersist(550과 동일). ──
  const restoreDoneRef = useSR2(false);
  const pendingRestoreRef = useSR2(null);
  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 !== '상품배너 H1') 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({ ...DEFAULT_SCORER_STATE, ...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.focals.bsp !== undefined) setBSplit(ed.focals.bsp);
    }
    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.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);
    if (logoUrls.length) {
      setLogoSrcs(logoUrls);
      if (Array.isArray(ov.logoOrients)) {
        const nextLogoOrients = {};
        logoUrls.forEach((url, i) => { if (ov.logoOrients[i] === 'h' || ov.logoOrients[i] === 'v') nextLogoOrients[url] = ov.logoOrients[i]; });
        setLogoOrientOverrides(nextLogoOrients);
      }
      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;
    }
    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 + '장');
    pendingRestoreRef.current = { pinnedId: ed.pinnedId || null, editRects: (ed.editRects && ed.editRects.length) ? ed.editRects : null };
    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) {} }
  }, []);
  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;
    setPinnedId(pid);
    if (pend.editRects && cand.rects) {
      const merged = cand.rects.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]);

  // 이미지 세트가 바뀌면 영역별 후보를 새로 인덱싱한다. 이전 후보의 id/좌표/고정 상태를
  // 재사용하면 삭제 후 재추가된 이미지가 이전 영역을 물려받아 패턴과 z-index가 깨진다.
  useSE2(() => {
    const pending = pendingSourceEditsRef.current;
    if (!pending) return;
    const prepared = new Map();
    pending.replacements.forEach((pair) => {
      const item = allItems.find((candidate) => candidate && (candidate.src === pair.nextSrc || candidate.url === pair.nextSrc));
      if (item) prepared.set(pair.oldSrc, item);
    });
    if (prepared.size !== pending.replacements.length) return;
    let changed = false;
    const rects = pending.rects.map((rect) => {
      const pair = pending.replacements.find((candidate) => rectMatchesSources(rect, new Set(candidate.oldKeys || [candidate.oldSrc])));
      if (!pair) return rect;
      const replacement = prepared.get(pair.oldSrc);
      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);
    pendingSourceEditsRef.current = null;
  }, [allItems]);

  // 저장 복원 직후에는 pendingRestore가 후보 생성 뒤 저장된 배치를 다시 입힌다.
  useSE2(() => {
    if (pendingRestoreRef.current) return;
    if (preserveSrcChangeRef.current) {setSelectedId(null);return;}
    setEditRects({});setPatLock(null);setHistory([]);setRedo([]);setSelectedId(null);setPinnedId(null);
    setRestoredCand(null);
    setBPinned(null);setSplitPinned(null);setFullPinned(null);setBFocal(null);setBSplit(null);setSplitFocals({});setFullFocals({});
    setBPhotoSrc((p) => p && srcs.includes(p) ? p : 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;};
  const fashionOnly = layoutType === 'A' && productItems.length === 0 && modelItems.length > 0;
  const headerCandidateCount = fashionOnly ? fashionCands.length : ranked.length;

  return (
    <div className="ap550">
      {/* ── 에디터 헤더: 좌 배너이름+정보 / 우 저장하기 ── */}
      <header className="ap-editor-header">
        <div className="ap-eh-left">
          <span className="ap-eh-title">상품배너 H1</span>
          {(headerCandidateCount > 0 || productItems.length > 0 || modelItems.length > 0) && <span className="ap-eh-info">{headerCandidateCount > 0 && <b>{headerCandidateCount}개 후보</b>}{headerCandidateCount > 0 && (productItems.length > 0 || modelItems.length > 0) && ' · '}{productItems.length > 0 && `상품 ${productItems.length}`}{productItems.length > 0 && modelItems.length > 0 && ' + '}{modelItems.length > 0 && `모델 ${modelItems.length}`}</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 = '';}} />

          <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', fontFamily: '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', fontFamily: '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, fontFamily: '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" data-h1-upload-thumbs style={{ gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', alignItems: 'start' }}>
                    {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)}
                          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', minWidth: 0, width: '100%', alignSelf: 'start' }}>
                        <div className="ap-thumb-media" style={{ width: '100%', minWidth: 0, flex: '0 0 auto' }}>
                          <img src={src} alt="" draggable={false} style={{ display: 'block', minWidth: 0, maxWidth: '100%' }} />
                          {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>;})()}
                          {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 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>);

                  })}
                    <button className="ap-thumb-add" onClick={() => upRef.current.click()} style={{ minWidth: 0, width: '100%', alignSelf: 'start' }} {...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', minWidth: 0, width: '100%', alignSelf: 'start', border: '1.5px solid #d8d8e0', borderRadius: '9px', color: '#8a8a96', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '4px', fontFamily: '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>
                </>}
              {srcs.length > 0 && <button type="button" onClick={armPlacement} style={{ width: '100%', marginTop: '10px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '7px', border: 'none', borderRadius: '10px', background: placementArmed ? '#eef2ff' : '#4E4CDB', color: placementArmed ? '#4E4CDB' : '#fff', fontFamily: 'inherit', fontSize: '13px', fontWeight: 800, cursor: 'pointer' }}>
                {placementArmed ? '배치 다시 적용' : '배치하기'}
              </button>}
              <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', fontFamily: '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', fontFamily: '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, fontFamily: '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>}
              </>}
              {/* 카테고리별 이미지 */}
              <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>}
              {srcs.length > 0 && <>
                <div style={{ fontSize: '11.5px', color: '#6b6b78', marginTop: '8px', marginBottom: '6px', fontWeight: 600 }}>선택됨 {srcs.length}장 — <button type="button" onClick={clearAll} style={{ border: 'none', background: 'none', color: '#4E4CDB', cursor: 'pointer', fontFamily: 'inherit', fontSize: '11.5px', fontWeight: 700, padding: 0 }}>전체 해제</button></div>
                <div className="ap-thumbs" data-library-selected-thumbs style={{ marginBottom: '8px' }}>
                  {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>
              </>}
              {srcs.length > 0 && <button type="button" onClick={armPlacement} style={{ width: '100%', marginTop: '10px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '7px', border: 'none', borderRadius: '10px', background: placementArmed ? '#eef2ff' : '#4E4CDB', color: placementArmed ? '#4E4CDB' : '#fff', fontFamily: 'inherit', fontSize: '13px', fontWeight: 800, cursor: 'pointer' }}>
                {placementArmed ? '배치 다시 적용' : '배치하기'}
              </button>}
              <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', fontFamily: '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>
            </div>}
          </section>


          {/* ── step 3: 배치 패턴 (A 누끼형 + 이미지가 있을 때만) ── */}
          {placementArmed && 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', fontFamily: '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' }}>
                <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', fontFamily: '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', fontFamily: '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="좌우 제품 간 X축 겹침만 조정" />
                <MiniSlider label="세로 겹침 허용치" value={scorer.verticalOverlapTol == null ? 0.1 : scorer.verticalOverlapTol} min={0} max={0.35} step={0.01} fmt={(v) => Math.round(v * 100) + '%'} onChange={(v) => setScorer((s) => ({ ...s, verticalOverlapTol: v }))} hint="행 간 Y축 겹침만 조정" />
              </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' }}>
            {BANNER_TYPES.filter((t) => t.ready !== false && !(t.id === 'B' && hideBTab)).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, fontFamily: '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>;
            })}
            {currentPattern && currentPattern.patternId && <span title={currentPattern.pattern && currentPattern.pattern.label || ''} style={{ fontSize: '10.5px', fontWeight: 700, color: '#4E4CDB', background: '#eef2ff', padding: '3px 9px', borderRadius: '20px', fontFamily: 'monospace', letterSpacing: '0.01em', whiteSpace: 'nowrap', border: '1px solid rgba(78,76,219,0.18)' }}>{currentPattern.patternId}</span>}
            {layoutType !== 'C' && <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} 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} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} /> },
            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> :
          !placementArmed ?
          <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="M9 12h6M12 9v6" /></svg></div><p className="ap-empty-t">이미지 선택 완료 후 배치하세요</p><p className="ap-empty-s">누끼 작업과 이미지 선택을 마친 뒤 배치하기를 누르면 후보를 생성합니다.</p><button className="ap-btn primary" onClick={armPlacement}>배치하기</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={() => { const target = fullPrimary.src || fullPrimary.url; if (target) removeSourcesPreservePlacement([target]); }} guideOn={guideOn} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} /> },
            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> · 공식 동일 폭 열 + 확장 비율 후보{fullItems.length > 4 ? ' · +' + (fullItems.length - 4) + '장은 미사용' : ''}</>,
            primary: { cand: splitPrimary, name: splitPrimary.rowsLabel, desc: splitPrimary.metrics && splitPrimary.metrics.guideApproved ? '가이드 동일 폭 세로 분할' : '확장 비율 후보 · 저장 전 검수 대상', recBadge: !!(splitPrimary.metrics && splitPrimary.metrics.guideApproved), edit: { ...focalEdit, onReset: resetAll, onApplyGuide: applyGuideRules },
              stage: <SplitStage panels={splitPrimary.panels} focalOf={(i, base) => splitFocalOf(splitPrimary.id, i, base)} onFocal={(i, f) => setSplitFocal(splitPrimary.id, i, f)} onBegin={focalBegin} dispW={RESULT_DISP} logos={logoMeta} onDeletePanel={(pn) => { const target = pn && (pn.src || pn.url); if (target) removeSourcesPreservePlacement([target]); }} guideOn={guideOn} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} /> },
            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) => <div key={k} style={{ position: 'absolute', left: pn.x / 776 * 100 + '%', top: pn.y / 388 * 100 + '%', width: pn.w / 776 * 100 + '%', height: pn.h / 388 * 100 + '%', overflow: 'hidden' }}><img src={pn.url} alt="" draggable={false} style={panelImgStyle(pn.focal, pn)} /></div>),
            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> · 좌 누끼 상품 + 우 화보(클릭→조정박스)</>,
            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={setSelectedId} onChange={onPrimaryChange} onBegin={beginEdit} bounds={bPrimary.bounds} scorer={scorer} photo={bPhoto} focal={bFocalOf()} onFocal={setBFocal} onFocalBegin={focalBegin} split={B_PHOTO.x} onSplit={(s) => setBSplit(s)} dispW={RESULT_DISP} logos={logoMeta} onDeleteItem={onCanvasDeleteItem} onDeletePhoto={() => { if (bPhoto) removeSourcesPreservePlacement([bPhoto.src]); }} guideOn={guideOn} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} onSelectionChange={setSelectedIds} /> },
            secondary: bSecondary && { cand: bSecondary, name: bSecondary.strategyLabel || '분할+누끼', desc: bSecondary.rowsLabel, onMakePrimary: () => pickBPinned(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: pickBPinned, showRanking: true,
            thumb: (c) => <>{bPhoto && <img src={bPhoto.url} alt="" draggable={false} style={{ position: 'absolute', left: B_PHOTO.x / 776 * 100 + '%', top: 0, width: (776 - B_PHOTO.x) / 776 * 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 / 776 * 100 + '%', top: r.y / 388 * 100 + '%', width: r.w / 776 * 100 + '%', height: r.h / 388 * 100 + '%', objectFit: 'contain' }} />)}</>,
            hint: <>좌측은 <b>누끼 상품 배열</b>(아래 <b>후보 랭킹</b>에서 선택), 우측은 화보 1장입니다. <b>화보를 클릭</b>하면 조정박스가 나타납니다. NW·SW 핸들로 폭 조절, SE 핸들로 확대/축소, 안쪽 드래그로 초점 이동.</> }} /> :
          <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={pickPinned} logos={logoMeta} deco={deco} decoPos={decoPos} onDecoPosChange={setDecoPosPreserved} onDecoScale={setDecoScalePreserved} decoSelected={decoSelected} onSelectDeco={onSelectDeco} selectedId={selectedId} onSelect={setSelectedId} 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} 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 && <APResultCardH1 dispW={RESULT_DISP} cand={primary} rects={rectsFor(primary)} metrics={metricsFor(primary)} rank={rankOf(primary.id)} primary selectedId={selectedId} onSelect={setSelectedId} 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={h1NukkiPatternItems.length} logos={logoMeta} deco={deco} decoPos={decoPos} onDecoPosChange={setDecoPosPreserved} onDecoScale={setDecoScalePreserved} decoSelected={decoSelected} onSelectDeco={onSelectDeco} onDeleteItem={onCanvasDeleteItem} guideOn={guideOn} 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', fontFamily: '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) => <APMiniCanvasH1 key={c.id} cand={c} rank={i + 1} active={c.id === primaryId} onClick={() => pickPinned(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 · 우측 상단 · 폭 50% 이내</span></div>
                <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
                  {logoSrcs.map((s, i) => {
                    const meta = logoMeta[i] || logoMeta.find((g) => g.url === s) || {};
                    const orient = logoOrientOverrides[s] || (meta && meta.orient) || 'h';
                    const sw = h1LogoScaleW(meta), sh = h1LogoScaleH(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="h1" 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: '1px solid #e7e7ee', borderRadius: '10px', background: '#fff', boxSizing: 'border-box' }}>
                        <div draggable onDragStart={(e) => {e.dataTransfer.effectAllowed = 'move';e.dataTransfer.setData('text/plain', 'logo:' + i);}} style={{ position: 'relative', width: '104px', 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={() => removeLogoAt(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 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={() => setLogoOrient(s, 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', fontFamily: 'inherit', fontSize: '10.5px', fontWeight: 800, cursor: 'pointer', lineHeight: 1.1 }}>{opt.label}</button>;
                          })}
                        </div>
                        <div data-logo-size-controls="h1" style={{ marginTop: '6px' }}>
                          <label style={rowStyle}><span style={{ width: '24px', fontSize: '10.5px', fontWeight: 800, color: '#6b6b78' }}>가로</span><input aria-label={'H1 로고 ' + (i + 1) + ' 가로 크기'} type="range" min="0.25" max="3" step="0.05" value={sw} onChange={(e) => setLogoSizeH1(i, 'w', parseFloat(e.target.value))} 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={'H1 로고 ' + (i + 1) + ' 세로 크기'} type="range" min="0.25" max="3" step="0.05" value={sh} onChange={(e) => setLogoSizeH1(i, 'h', parseFloat(e.target.value))} style={rangeStyle} /><b style={{ width: '32px', textAlign: 'right', fontSize: '10.5px', color: '#1c1c22' }}>{Math.round(sh * 100)}%</b></label>
                        </div>
                      </div>);
                  })}
                  {logoSrcs.length < H1_LOGO_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={H1_LOGO_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', fontFamily: '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 그룹 — 부가정보 4종: 누끼형(A)·풀이미지형(D)만. 이미지 표기는 부가정보가 아니며 분할/보험 유형은 제외. */}
              {(bannerType === 'A' || bannerType === 'D') &&
              <section className="ap-sec">
                <div className="ap-sec-head" style={{ margin: '0 0 8px' }}>부가 정보<span className="ap-opt">4종 중 1개 · 다시 눌러 해제</span></div>
                <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
                  {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', fontFamily: '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', fontFamily: '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', fontFamily: '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', fontFamily: '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', alignItems: 'center', gap: '6px', marginTop: '10px' }}>
                      <span style={{ fontSize: '11px', color: '#6b6b78', fontWeight: 700, marginRight: 'auto' }}>배치</span>
                      <button type="button" onClick={() => setTextPlacement('default')} title="가이드 우상단 위치로 배치" style={{ padding: '5px 9px', borderRadius: '7px', border: decoPlacementMode === 'default' ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: decoPlacementMode === 'default' ? '#eef2ff' : '#fff', color: decoPlacementMode === 'default' ? '#4E4CDB' : '#6b6b78', font: 'inherit', fontSize: '11px', fontWeight: 700, cursor: 'pointer' }}>우상단</button>
                      <button type="button" onClick={() => setTextPlacement('center')} title="캔버스 정중앙에 배치" style={{ padding: '5px 9px', borderRadius: '7px', border: decoPlacementMode === 'center' ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: decoPlacementMode === 'center' ? '#eef2ff' : '#fff', color: decoPlacementMode === 'center' ? '#4E4CDB' : '#6b6b78', font: 'inherit', fontSize: '11px', fontWeight: 700, cursor: 'pointer' }}>가운데</button>
                    </div>
                    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px', marginTop: '8px' }}>
                      <label style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', fontWeight: 700, color: '#6b6b78' }}>X<input aria-label="부가텍스트 X 좌표" type="number" min="0" max={Math.max(0, 776 - (deco ? deco.w : 90))} step="1" value={h1DecoCurrentPos ? Math.round(h1DecoCurrentPos.x) : ''} disabled={!deco} onChange={(e) => setDecoCoord('x', e.target.value)} style={{ width: '100%', minWidth: 0, padding: '6px 7px', borderRadius: '7px', border: '1px solid #e7e7ee', background: deco ? '#fff' : '#f1f1f4', font: 'inherit', fontSize: '11px', boxSizing: 'border-box' }} /></label>
                      <label style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', fontWeight: 700, color: '#6b6b78' }}>Y<input aria-label="부가텍스트 Y 좌표" type="number" min="0" max={Math.max(0, 388 - (deco ? deco.h : 90))} step="1" value={h1DecoCurrentPos ? Math.round(h1DecoCurrentPos.y) : ''} disabled={!deco} onChange={(e) => setDecoCoord('y', e.target.value)} style={{ width: '100%', minWidth: 0, padding: '6px 7px', borderRadius: '7px', border: '1px solid #e7e7ee', background: deco ? '#fff' : '#f1f1f4', font: 'inherit', fontSize: '11px', boxSizing: 'border-box' }} /></label>
                    </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', fontFamily: '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', fontFamily: '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', fontFamily: '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>)}
                    </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', fontFamily: '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', fontFamily: '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', fontFamily: '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', fontFamily: '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>
                    <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', fontFamily: '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: '상품배너 H1', type: (BANNER_TYPES.find((t) => t.id === bannerType) || {}).label || '', fileNameRule: window.SCHEMA_H1 && window.SCHEMA_H1.fileNameRule, fixedValues: { category: h1CategoryCode }, 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) 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 p = normalizeRestoredAddImgPos(deco.pos);
    const renderZoom = p.zoom;
    const heading = deco.title != null ? String(deco.title).trim() || t.label : t.label;
    const headingMax = t.icon ? 68 : 112;
    const headingFs = Math.max(9, Math.min(20, Math.floor(headingMax / Math.max(1, heading.length) * 1.55)));
    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: headingFs + 'px', fontWeight: 600, letterSpacing: 0, maxWidth: headingMax + 'px', whiteSpace: 'nowrap', overflow: 'hidden', textAlign: 'center', lineHeight: 1 }}>{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={deco.imgKind === 'nukki' ? { width: '100%', height: '100%', objectFit: 'contain', padding: '12%', boxSizing: 'border-box', transform: `scale(${renderZoom})`, transformOrigin: (p.x || 50) + '% ' + (p.y || 50) + '%' } : { width: '100%', height: '100%', objectFit: 'contain', objectPosition: (p.x || 50) + '% ' + (p.y || 50) + '%', transform: `scale(${renderZoom})`, transformOrigin: (p.x || 50) + '% ' + (p.y || 50) + '%' }} /> : null}</div>
      </div>);
  }
  if (deco.kind === 'flag') {
    return <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', alignItems: 'flex-end' }}>{Array.from({ length: deco.count }).map((_, i) => <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)' }}><span style={{ color: '#fff', fontSize: '17px', fontWeight: 700, lineHeight: 1.12, textAlign: 'center', transform: 'translateY(-0.055em)' }}>인증<br />플래그</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;
}
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', fontFamily: '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', fontFamily: '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, imageAdjust, imageAdjustBySource, onSelectionChange }) {
  const modelCount = rects.filter((rect) => rect && rect.role === 'model').length;
  const rowsLabel = String(cand.rowsLabel || modelCount + '인').replace(/^\d+인/, modelCount + '인').replace(/(?: · 배치 유지)+$/, ' · 배치 유지');
  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">{rowsLabel} · 세이프 가득 · 하반신 크롭</span></span></div>
          <APStageH1 rects={rects} dispW={RESULT_DISP} 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} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} onSelectionChange={onSelectionChange} />
          <div className="ap-metrics">
            <APMetricRowH1 k="모델 수" v={modelCount + '인'} strong />
            <APMetricRowH1 k="머리 상단" v="세이프 상단 정렬" tone="good" />
            <APMetricRowH1 k="가장자리 얼굴" v="세이프 좌·우 안" tone="good" />
            <APMetricRowH1 k="하단" v="하반신 자연 크롭" />
          </div>
          <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) => <APMiniCanvasH1 key={c.id} cand={c} rank={i + 1} active={c.id === activeId} onClick={() => onPick(c.id)} />)}</div></div>}
      <p className="ap-hint">모델은 클릭 후 <b>드래그로 위치</b>를 조정하고, 우하단 모서리 핸들로 <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: '776/388', 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, decoSelected, onDecoResizeDown) {
  // H1 가이드 (776×388): 상20·우20·간격10·h=20(가로형)/60(세로형)·최대폭=388(50%)
  const out = [];let logoBottom = 20;
  if (logos && logos.length) {
    const row = h1LogoRow(logos);
    const top = 20;
    for (let i = row.items.length - 1; i >= 0; i--) {const w = row.items[i].w, h = row.items[i].h;const x = (row.items[i].rightEdge || 776 - 20) - w;out.push(<img key={'lg' + i} src={row.items[i].url} alt="" draggable={false} style={{ position: 'absolute', left: x, top: top + (row.rowH - h) / 2, width: w, height: h, objectFit: 'contain' }} />);}
    logoBottom = top + row.rowH;
  }
  if (deco && window.DecoBadge) {
    const pos = decoPos && typeof decoPos === 'object' ? decoPos : null;
    const def = deco.kind === 'image'
      ? { x: 776 - 20 - (deco.w || 90), y: 388 - 20 - (deco.h || 90) }
      : { x: 776 - 20 - (deco.w || 90), y: logos && logos.length ? logoBottom + 30 : 20 };
    const resolved = window.H1DecoPlacement
      ? window.H1DecoPlacement.clamp(pos && (pos.x != null || pos.y != null) ? pos : def, deco)
      : { x: pos && pos.x != null ? Math.max(0, Math.min(776 - (deco.w || 90), Number(pos.x) || 0)) : def.x, y: pos && pos.y != null ? Math.max(0, Math.min(388 - (deco.h || 90), Number(pos.y) || 0)) : def.y };
    const top = resolved.y;
    const left = resolved.x;
    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="h1" 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="h1" 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 스테이지 공용 — H1 좌표계, 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>
    </>
  );
}
// 여백 가이드 토글 버튼 (B/C/D/E 스테이지 공용, position: absolute 기준 부모)
/* E 보험 레이아웃(550 좌표계) — 로고+보험명 좌상단, 일러스트 중앙-하단, 모델(선택) 우측 밴드, 부가정보 좌하단.
   미리보기(EStage)와 다운로드(renderEBlob)가 같은 좌표를 쓰도록 단일 출처. */
// E 보험 템플릿 위치·크기 상수 (한 곳). 여기 값만 바꾸면 미리보기·다운로드 동시 반영.
const E_CFG = {
  W: 776, H: 388, safeX: 50, safeY: 40, safeBottom: 30, // 캔버스·세이프(좌우50·상40·하30)
  logoH_h: 40, logoH_v: 60, logoGap: 10,         // [H1 776×388] 로고 높이: 가로형 40 / 세로형 60
  nameFont: 32,                                   // [H1] 보험명 폰트(1줄이 흔함) — 레퍼런스 실측
  logoTop: 40,                                    // [H1] 배너 상단 → 로고 (= 상단 세이프 40)
  nameGap: 18,                                    // [H1] 로고 아래 → 보험명 시작 간격 (절대 px, 레퍼런스 11px→×1.64)
  nameToModel: 12, nameToIllust: 78,             // 보험명 우측↔모델 간격 / 보험명 아래→일러스트 여유
  modelMaxWRatio: 0.46, modelAspect: 0.62,       // (구)모델 밴드 최대 폭 비율 / 기본 비율
  // [H1 인물누끼] 패션식 — 우측 여백(오프스크린 아님) · 왼쪽 끝=가로중앙 · 정수리=상단 세이프 · 상체 중심(하단은 프레임이 크롭). 레이어 최전면, 무크롭 aspect-fit.
  modelAnchor: { mode: 'fashion', rightMarginFrac: 0.1, leftLimitFrac: 0.6667 }, // 우측여백 최소 · 인물 왼쪽끝=우측 1/3 지점(2/3 W)까지만 커짐
  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.safeBottom != null ? C.safeBottom : C.safeY) }; // 상단 safeY·하단 safeBottom
  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)
  // [인물누끼] 패션식 앵커 — 모델 rect를 먼저 계산(로고·보험명을 인물 좌측 영역 중앙에 놓기 위해).
  //   우측 여백 · 왼쪽 끝=가로중앙 · 정수리=상단 세이프 · 상체 중심(하단은 프레임이 크롭). 무크롭 aspect-fit, 레이어 최전면.
  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.5); // 인물 우측 여백
    const mx = C.W * (A.leftLimitFrac != null ? A.leftLimitFrac : 0.5); // 왼쪽 끝 = 가로 중앙
    const w = (C.W - rightMargin) - mx;const h = w / asp; // 폭 기준 → 상체 중심(하체는 프레임 크롭)
    model = { x: mx, y: C.safeY, w, h, fit: 'nukki' };
  }
  // 로고·보험명 중앙정렬 영역 — 인물 있으면 [세이프좌, 인물좌], 없으면 [세이프좌, 세이프우]. (배경은 full-bleed 유지)
  const regL = S.x, regR = hasModel ? model.x : S.r, regC = (regL + regR) / 2;
  const logos = [];let logoRowH = 0;
  if (logoMeta.length) {
    const row = h1LogoRow(logoMeta, (g) => (g.orient === 'h' || h1LogoAspect(g.aspect) >= 1.6) ? logoHh : logoHv, Math.min(H1_LOGO_MAX_ROW, regR - regL));
    logoRowH = row.rowH;
    let x = regC - row.rowW / 2; // 영역(인물 좌측) 중앙 정렬
    row.items.forEach((a) => {logos.push({ url: a.url, x, y: logoTop, w: a.w, h: a.h });x += a.w + row.gap;});
  }
  const nameY = logoTop + (logoRowH ? logoRowH + nameGapV : 0);
  const 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=보험명 줄 수(H1은 단일 라인).
  const nameLines = opts.nameLines || 0;
  const textBottom = nameLines > 0 ? nameY + Math.round(nameLines * nameFont * 1.2) : (logoRowH ? logoTop + logoRowH : S.y);
  // [보험 배경] 일러스트는 항상 "보험명 아래 ~ 하단 세이프, 좌측 영역(regL~regR: 인물 있으면 인물 좌측·중앙정렬)"에 하단앵커 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(APStageH1)는 contain 전용이라 D 전용 cover 렌더를 이 파일 안에 둔다(공유 파일 무수정). */
function FullStage({ url, source, overflow, focal, onFocal, onBegin, dispW = RESULT_DISP, logos, deco, decoPos, onDecoPosChange, onDecoScale, decoSelected, onSelectDeco, onDeleteImage, guideOn = true, imageAdjust, imageAdjustBySource }) {
  const scale = dispW / 776;
  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) => window.H1DecoPlacement
    ? window.H1DecoPlacement.clamp(p, d)
    : { x: Math.max(0, Math.min(776 - (d.w || 90), Number(p && p.x) || 0)), y: Math.max(0, Math.min(388 - (d.h || 90), Number(p && p.y) || 0)) };
  const defaultDecoPos = (() => {
    if (!deco) return { x: 0, y: 0 };
    let logoBottom = 20;
    if (logos && logos.length) {
      logoBottom = 20 + h1LogoRow(logos).rowH;
    }
    if (window.H1DecoPlacement) return window.H1DecoPlacement.defaultPosition(deco, logos && logos.length ? logoBottom : null);
    if (deco.kind === 'image') return { x: 776 - 20 - (deco.w || 90), y: 388 - 20 - (deco.h || 90) };
    return { x: 776 - 20 - (deco.w || 90), y: logos && logos.length ? logoBottom + 30 : 20 };
  })();
  const currentDecoPos = deco ? clampDecoPos(decoPos && typeof decoPos === 'object' ? decoPos : defaultDecoPos, deco) : null;
  // 자유 이동(2축) — 확대(zoom>1) 시 양축 여백 생김. cover 상태(zoom=1)에선 넘치는 축만 유효.
  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 = clampPct(d.x - (ev.clientX - d.sx) / d.w * 100);const ny = ev.shiftKey ? d.y : clampPct(d.y - (ev.clientY - d.sy) / d.h * 100);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);};
  // 코너 핸들 드래그로 크기(확대) 직접 조절 — 550/앱푸시 리사이즈와 동일한 방식(버튼 X)
  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) => {
    if (!onFocal) return;
    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, decoSelected, onDecoScale ? startDecoResize : null);
  const h1Safe = (window.AP_H1 && window.AP_H1.SAFE) || { x: 50, y: 50 };
  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} style={{ width: dispW, height: dispW * 388 / 776, 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: 'grab', 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: 776, height: 388, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none', zIndex: 9000 }}>
        {guideOn && <SafeGuideOverlay sx={h1Safe.x} sy={h1Safe.y} cw={776} ch={388} />}
        {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.AP_H1.recognitionGrade(m.exposure) : null;
  // [정수리 규칙] 분할형 인물 칸의 머리 위 여백 — 칸별 px + 실측/추정 표시. 하한 미달 칸은 warn.
  const heads = (cand && cand.panels || []).filter((pn) => pn.headTopPx != null);
  const headRow = heads.length ? [{
    k: '정수리 여백 (칸별)',
    v: heads.map((pn) => pn.headTopPx + (pn.headMeasured ? '' : '?')).join(' / ') + 'px' + (heads.some((pn) => !pn.headMeasured) ? ' · ?=추정' : ''),
    tone: heads.some((pn) => pn.headShort) ? 'warn' : 'good', strong: true }] : [];
  const rows = headRow.concat([
    { 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.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.APMetricRowH1, { key: i, k: r.k, v: r.v, tone: r.tone, strong: r.strong }))}</window.APMetFold>}
      <div className="ap-card-actions">{primary ? <>
        {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', height: '50px' }}>{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>}
    </>);

}
/* C 분할형 스테이지 — 공식 후보는 동일 폭 2/3/4 세로 열, 확장 후보는 콘텐츠 비율 폭.
   모든 패널은 전체 캔버스를 채우고 로고만 공통 오버레이한다. */
function panelOverflow(pn) {const pa = pn.w / pn.h, ia = pn.aspect || 1;return ia > pa + 1e-3 ? 'x' : ia < pa - 1e-3 ? 'y' : null;}
/* 패널 화보의 focal(초점 + 확대)을 CSS로 옮기는 단일 출처 — 큰 미리보기·후보 썸네일 공용.
   저장 경로(coverCrop)와 같은 해석이어야 한다. zoom을 빼면 focal.y가 전제한 확대가 사라져 정수리 위치가 틀어진다. */
// [SPLIT-FIX] 분할형 패널 이미지 스타일.
//   패널은 기본 cover로 셀을 채우며, focal은 objectPosition과 확대 중심에 함께 사용한다.
function panelImgStyle(f, pn) {
  const z = (f && f.zoom) || 1, x = (f && f.x != null ? f.x : 50), y = (f && f.y != null ? f.y : 50);
  const fit = pn && pn.fit === 'cover' ? 'cover' : (z > 1 ? 'cover' : 'contain');
  return { width: '100%', height: '100%', objectFit: fit, objectPosition: x + '% ' + y + '%', ...(z !== 1 ? { transform: 'scale(' + z + ')', transformOrigin: x + '% ' + y + '%' } : {}), pointerEvents: 'none' };
}
function SplitStage({ panels, focalOf, onFocal, onBegin, dispW = RESULT_DISP, logos, onDeletePanel, guideOn = true, imageAdjust, imageAdjustBySource }) {
  const scale = dispW / 776;
  const panRef = 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(기본 상태 포함) + 휠/모서리 확대. cover 셀 안에서 초점을 직접 옮긴다.
  const startPan = (e, i, pn) => {
    if (e.button === 2) return;
    if (onFocal) setResizePanel(i);
    if (!onFocal) return;
    const cur = focalOf(i, pn.focal);
    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 = ev.shiftKey ? d.y : 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) => {
    if (!onFocal) return;
    e.preventDefault(); e.stopPropagation();
    setMenu({ x: e.clientX, y: e.clientY, i });
  };
  const openPanelMenuAtPoint = (e) => {
    if (!onFocal) return;
    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] || 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) => Number.isFinite(p[k]) ? 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 h1Safe = (window.AP_H1 && window.AP_H1.SAFE) || { x: 50, y: 50 };
  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} style={{ width: dispW, height: dispW * 388 / 776, position: 'relative', overflow: 'hidden', borderRadius: 0, background: '#fff' }}>
      {panels.map((pn, i) => {const f = focalOf(i, pn.focal);const canPan = onFocal && (f.zoom || 1) > 1;return (
        <div key={i} data-split-panel-index={i} data-photo-panel-selected={resizePanel === i ? 'true' : 'false'} onPointerDown={(e) => 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: onFocal ? 'grab' : 'default', touchAction: 'none', background: '#fff', zIndex: 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={{ ...panelImgStyle(f, pn), ...(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>);})}
      <div style={{ position: 'absolute', top: 0, left: 0, width: 776, height: 388, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none', zIndex: 9000 }}>
        {apOverlayEls(logos, null)}
      </div>
      {menuPortal}
    </div>);

}
/* B 분할+누끼형 스테이지 — 좌 2/3에 누끼 클러스터(contain, A 엔진 배치 그대로), 우 1/3에 화보(cover+초점 드래그).
   좌측 누끼는 시안(배열)으로 다양성 제공(읽기 전용 v1), 우측 화보만 초점 조정. */
function SplitNukkiStage({ rects, editable, selectedId, onSelect, onChange, onBegin, bounds, scorer, photo, focal, onFocal, onFocalBegin, onSplit, split = 330, dispW = RESULT_DISP, logos, onDeleteItem, onDeletePhoto, guideOn = true, imageAdjust, imageAdjustBySource, onSelectionChange }) {
  const CANVAS_W = 776, CANVAS_H = 388;
  const scale = dispW / CANVAS_W;
  // 화보 조정박스 선택 상태: 클릭 시 파란 테두리 + 코너 핸들 표시.
  const [photoSel, setPhotoSel] = React.useState(false);
  const f = focal || { x: 50, y: 50, zoom: 1 };
  const zoom = f.zoom || 1;
  const panRef = React.useRef(null);
  const pw = CANVAS_W - split;
  const [photoMenu, setPhotoMenu] = React.useState(null);
  const [photoZ, setPhotoZ] = React.useState(50);
  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]);
  // 제품 onSelect wrapper — 화보 선택 해제 + 상위 전달.
  const handleProductSelect = (id) => { setPhotoSel(false); onSelect && onSelect(id); };
  // 화보 패널 클릭: 선택 상태 전환 + 팬 시작(동시).
  const handlePhotoDown = (e) => {
    if (e.button === 2) return;
    if (editable) { setPhotoSel(true); onSelect && onSelect(null); }
    if (!onFocal) return;
    e.preventDefault(); e.stopPropagation();
    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 = clampPct(d.x - (ev.clientX - d.sx) / d.w * 100);const ny = ev.shiftKey ? d.y : clampPct(d.y - (ev.clientY - d.sy) / d.h * 100);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 onWheel = (e) => {if (!onFocal) return;e.preventDefault();e.stopPropagation();onFocal({ x: f.x, y: f.y, zoom: Math.max(1, Math.min(3, +(zoom - e.deltaY * 0.0015).toFixed(3))) });};
  // SE 코너 핸들: crop zoom. undo 체크포인트 생성.
  const startResize = (e) => {
    if (e.button === 2) return;
    if (!onFocal) return;
    onFocalBegin && onFocalBegin();
    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;onFocal({ x: f.x, y: f.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 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 <= CANVAS_W && y >= 0 && y <= CANVAS_H) {
      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(); };
  // NW/SW 코너 핸들: 화보 패널 너비 조절(split 이동). undo 체크포인트 생성.
  const startSplitDrag = (e) => {
    if (!onSplit) return;
    onFocalBegin && onFocalBegin();
    e.preventDefault(); e.stopPropagation();
    const s0 = split, sx = e.clientX;
    const MIN_SP = Math.round(CANVAS_W * 0.3); // 화보 최대 ~70%
    const MAX_SP = Math.round(CANVAS_W * 0.85); // 화보 최소 ~15%
    const mv = (ev) => {const dx = (ev.clientX - sx) / scale;onSplit(Math.max(MIN_SP, Math.min(MAX_SP, Math.round(s0 + dx))));};
    const up = () => {window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv); window.addEventListener('pointerup', up);
  };
  const h1Safe = (window.AP_H1 && window.AP_H1.SAFE) || { x: 50, y: 50 };
  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;
  // 코너 핸들 치수: 10px 정사각형, 중심이 모서리에 위치.
  const HW = 10, HR = 5;
  return (
    <div onContextMenu={openStageMenuAtPoint} style={{ position: 'relative', width: dispW, height: dispW * CANVAS_H / CANVAS_W, margin: '0 auto', overflow: 'hidden', isolation: 'isolate' }}>
      {React.createElement(window.APStageH1, { rects, dispW, editable, selectedId, onSelect: handleProductSelect, onChange, onBegin, bounds, scorer, faintSafe: true, logos: [], onDeleteItem: editable ? onDeleteItem : null, lockPositionResizeOnly: false, allowModelResize: editable, imageAdjust, imageAdjustBySource, onSelectionChange })}
      {photo && <div onPointerDown={handlePhotoDown} onContextMenu={openPhotoMenu} onWheel={onWheel} style={{ position: 'absolute', left: split * scale, top: 0, width: pw * scale, height: CANVAS_H * scale, overflow: 'hidden', cursor: photoSel ? 'move' : 'pointer', touchAction: 'none', zIndex: photoZ }}>
        <img src={photo.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, photo.src || photo.url) : imageAdjust) : {}) }} />
        {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(가이드52·보더55 위). 여백 가이드 pointer-events:none → 핸들 클릭 방해 없음.
          NW/SW: ew-resize(split 조절), NE: visual-only, SE: nwse-resize(crop zoom). */}
      {editable && photoSel && photo && <>
        <div data-photo-selection-tool="split-nukki" style={{ position: 'absolute', left: split * scale, top: 0, width: pw * scale, height: CANVAS_H * scale, border: '1.5px solid #4E4CDB', boxSizing: 'border-box', pointerEvents: 'none', zIndex: 57 }} />
        <div onPointerDown={startSplitDrag} title="드래그로 화보 영역 폭 조절" style={{ position: 'absolute', left: split * scale - HR, top: -HR, width: HW, height: HW, background: '#4E4CDB', border: '1.5px solid #fff', boxSizing: 'border-box', cursor: 'ew-resize', zIndex: 58, touchAction: 'none' }} />
        <div onPointerDown={startSplitDrag} title="드래그로 화보 영역 폭 조절" style={{ position: 'absolute', left: split * scale - HR, top: CANVAS_H * scale - HR, width: HW, height: HW, background: '#4E4CDB', border: '1.5px solid #fff', boxSizing: 'border-box', cursor: 'ew-resize', zIndex: 58, touchAction: 'none' }} />
        <div style={{ position: 'absolute', left: dispW - HR, top: -HR, width: HW, height: HW, background: '#4E4CDB', border: '1.5px solid #fff', boxSizing: 'border-box', pointerEvents: 'none', zIndex: 57 }} />
        <div onPointerDown={startResize} title="드래그로 화보 확대/축소" style={{ position: 'absolute', left: dispW - HR, top: CANVAS_H * scale - HR, width: HW, height: HW, background: '#4E4CDB', border: '1.5px solid #fff', boxSizing: 'border-box', cursor: 'nwse-resize', zIndex: 58, touchAction: 'none' }} />
      </>}
      {/* 여백 가이드 + 로고 오버레이 — z=52. pointer-events:none → 조정박스 핸들 클릭 방해 없음. */}
      <div style={{ position: 'absolute', top: 0, left: 0, width: CANVAS_W, height: CANVAS_H, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none', zIndex: 9000 }}>
        {guideOn && <SafeGuideOverlay sx={h1Safe.x} sy={h1Safe.y} cw={CANVAS_W} ch={CANVAS_H} />}
        {apOverlayEls(logos, null)}
      </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, onDeleteIllust, onDeleteModel, bg, textColor, dispW = RESULT_DISP, guideOn = true, imageAdjust, imageAdjustBySource }) {
  const scale = dispW / 776;
  const f = focal || { x: 50, y: 50 };
  const L = eLayout({ logoMeta: logos, illustAspect: illust && illust.aspect, illustContain, nameLines: String(name || '').trim() ? 1 : 0, 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 [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 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)) / 300;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 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 _nm = String(name || '').split(String.fromCharCode(10)).map((s) => s.trim()).filter(Boolean).join(' ');const lines = _nm ? [_nm] : [];
  const contentZ = modelFront ? 20 : 30;
  return (
    <div className="ap-stage-wrap" style={{ width: dispW, height: dispW * 388 / 776, 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: 776, height: 388, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none', zIndex: contentZ }}>
        {L.logos.map((lg, i) => <img key={i} src={lg.url} alt="" draggable={false} style={{ position: 'absolute', left: lg.x, top: lg.y, width: lg.w, height: lg.h, objectFit: 'contain' }} />)}
        {lines.length > 0 && <div style={{ position: 'absolute', left: L.nameX, top: L.nameY, width: L.nameRight - L.nameX, textAlign: L.nameCenter ? 'center' : 'left', whiteSpace: 'nowrap', 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 && L.decoBox && window.DecoBadge && <div style={{ position: 'absolute', left: L.decoBox.x * scale, top: L.decoBox.y * scale, zIndex: 40, transform: `scale(${scale})`, transformOrigin: 'top left' }}>{React.createElement(window.DecoBadge, { deco })}</div>}
      {guideOn && <div style={{ position: 'absolute', top: 0, left: 0, width: 776, height: 388, transform: `scale(${scale})`, transformOrigin: 'top left', pointerEvents: 'none', zIndex: 200 }}><SafeGuideOverlay sx={E_CFG.safeX} sy={E_CFG.safeY} cw={776} ch={388} /></div>}
      {assetMenuPortal}
    </div>);
}
/* [de-iframe 격리] IIFE 종료 — 밖으로는 이것 하나만 노출 */
window.AutoPlaceH1 = AutoPlaceTyped;
})();
