/* ============================================================
   nukki-push/app-typed.jsx — 앱푸시(720×380) 자동배치 화면 · 독립본
   ------------------------------------------------------------
   · 공유 팩토리 접고 유형별 독립 복사(사용자 결정). 이 파일은 앱푸시 전용.
   · 에디터 셸(AutoPlaceTyped)은 1E 것을 복사, 앱푸시 값/컴포넌트는 아래 PROFILE_PUSH·컴포넌트로.
   · 값(캔버스·좌표·폰트·색·컬러칩)은 전부 engine/schema-push.js(단일출처)에서 읽음(하드코딩 X).
   · 엔진(autoplace-push.js=AP_PUSH · autoplace-550.js=AP550) 재사용.
   ============================================================ */
(function () {
const { useState: useS2, useRef: useSR2, useEffect: useSE2, useMemo: useSM2 } = React;
const useDeferred2 = React.useDeferredValue || ((v) => v);

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));
}

// 1E 기술서상단 유형 — 누끼형(id 'A'=배치엔진 동작) / 화보형(준비 중)
const BANNER_TYPES = [
{ id: 'A', label: '누끼형', sub: '제품 누끼 · 컬러칩 단색 배경', ready: true },
{ id: 'editorial', label: '화보형', sub: '모델/인물 화보 · 톤 배경', ready: true }];

const COMMERCE_CATS = ['패션', '패션잡화', '뷰티', '식품/주방', '가구/가전/생활', '유아동/도서', '여행/보험'];
const APP_PUSH_CATALOG_ROOTS = {
  '패션': ['여성패션', '남성패션', '언더웨어', '스포츠/레저'],
  '패션잡화': ['패션잡화'],
  '뷰티': ['뷰티'],
  '식품/주방': ['식품', '주방용품'],
  '가구/가전/생활': ['가구/인테리어', '생활용품', '가전'],
  '유아동/도서': ['출산/유아동'],
  '여행/보험': ['렌탈/여행', 'TV상품'],
};
function catalogRootsForCommerce(category) {
  return APP_PUSH_CATALOG_ROOTS[category] || (category ? [category] : []);
}
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 : '';
}
// 1E 가이드 기준: 제품 최대 3(소형 5) · 인물 기본 2 최대 3 → 업로드 상한 prodMax:5 / modelMax:3
const CAT_RULES = {
  '패션': { prodMax: 5, modelMax: 3, modelCentric: true },
  '패션잡화': { prodMax: 5, modelMax: 3 }, '뷰티': { prodMax: 5, modelMax: 3 },
  '식품/주방': { prodMax: 5, modelMax: 3 }, '가구/가전/생활': { prodMax: 5, modelMax: 3 },
  '유아동/도서': { prodMax: 5, modelMax: 3 }, '여행/보험': { prodMax: 2, modelMax: 1, logo: true }
};
const DEFAULT_RULE = { prodMax: 5, modelMax: 3 };
const VISION_TO_COMMERCE = {
  '의류': '패션', '패션잡화': '패션잡화', '화장품': '뷰티', '의약품': '뷰티', '건강기능식품': '뷰티',
  '식품': '식품/주방', '음료': '식품/주방', '생활용품': '가구/가전/생활', '가전': '가구/가전/생활'
};
const TCAT_LABELS = ['식품', '음료', '의약품', '건강기능식품', '화장품', '의류', '패션잡화', '가전', '생활용품', '모델', '기타'];
const TCAT_NORM = { '음식': '식품', '과자': '식품', '차': '음료', 'tea': '음료', '커피': '음료', '생수': '음료', '술': '음료', '쉐이크': '음료', '두유': '음료', '코스메틱': '화장품', '뷰티': '화장품', '전자제품': '가전', '사람': '모델', '인물': '모델', '패션': '의류' };
const TCAT_PROMPT = '제품/모델 이미지다. 먼저 "이미지에 사람(인물)이 있으면 모델"로 본다. 사람이 없으면 브랜드·제품명·문구·섭취/사용 방식으로 상거래 대분류를 정한다. 첫 줄에 "카테고리: <단어>" 형식. 보기: ' + TCAT_LABELS.join(', ') + '. 옷을 사람이 입고 있으면 "모델", 옷만 있으면 "의류". 차·커피·주스·생수·쉐이크 등 마시는 것은 "음료", 과자·식사·식재료는 "식품".';
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) {
  const hasMp = !!window.__mpClassify;
  // 로컬(MediaPipe Pose)로 인물/상품 먼저 판별 — 사람이면 '모델'(LLM 불필요)
  if (hasMp) { try { const l = await window.__mpClassify(src); if (l === '모델') return '모델'; } catch (e) {} }
  // 어시스턴트·카탈로그 메타(categoryRoot/label) 있으면 상품 TCAT LLM 스킵
  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;
  if (hasMp && label === '모델') return null; // 로컬이 상품이라 판정 → LLM '모델'은 무시(상품 취급)
  return TCAT_LABELS.includes(label) ? label : label || null;
}
const isModelLabel = (l) => l === '모델';
async function analyzeModelHead(src) {
  // 로컬(MediaPipe Pose) 정수리·눈·어깨 실측 우선 — 성공 시 그 값 사용
  if (window.__mpHead) { try { const r = await window.__mpHead(src); if (r) return r; } catch (e) {} }
  // 폴백: 알파/흰배경 기반 — 실제 콘텐츠(머리) 상단과 머리 폭 검출 (비전 불필요, 오프라인 동작)
  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;
const PUSH_COPY_MAX_LINES = 2;
const PUSH_COPY_MAX_VISIBLE_PER_LINE = 10;
function pushCopyLimits(schema, form) {
  const SCH = schema || window.SCHEMA_PUSH || {};
  const f = form || {};
  const req = ((SCH.required || []).find((x) => x.id === 'copy')) || {};
  const baseBox = (SCH.slots && SCH.slots.copy) || {};
  const typeDef = (SCH.types || []).find((x) => x.id === (f.type || 'basic')) || {};
  const typeBox = typeDef.copyBox || {};
  return {
    lines: Math.max(1, Number(typeBox.maxLines || baseBox.maxLines || req.lines || PUSH_COPY_MAX_LINES) || PUSH_COPY_MAX_LINES),
    chars: Math.max(1, Number(typeBox.maxChars || baseBox.maxChars || req.maxChars || PUSH_COPY_MAX_VISIBLE_PER_LINE) || PUSH_COPY_MAX_VISIBLE_PER_LINE),
  };
}
const pushCopyVisibleLen = (s) => Array.from(String(s || '')).filter((ch) => ch !== ' ' && ch !== NL && ch !== '\t').length;
function pushCopyTakeVisible(s, max) {
  let out = '', n = 0;
  for (const ch of Array.from(String(s || ''))) {
    if (ch !== ' ' && ch !== NL && ch !== '\t') {
      if (n >= max) break;
      n++;
    }
    out += ch;
  }
  return out.trim();
}
function pushCopySplitLongWord(word, max) {
  const parts = [];
  let cur = '';
  for (const ch of Array.from(String(word || ''))) {
    if (pushCopyVisibleLen(cur + ch) > max && cur) {
      parts.push(cur);
      cur = ch;
    } else cur += ch;
  }
  if (cur) parts.push(cur);
  return parts;
}
function normalizePushCopy(raw, maxLines, maxChars) {
  const limitLines = maxLines || PUSH_COPY_MAX_LINES;
  const limitChars = maxChars || PUSH_COPY_MAX_VISIBLE_PER_LINE;
  const src = String(raw || '').replace(/\r/g, '').replace(/\t/g, ' ');
  const paragraphs = src.split(NL).map((line) => line.trim()).filter(Boolean);
  if (src.indexOf(NL) >= 0) {
    return paragraphs.slice(0, limitLines)
      .map((line) => pushCopyTakeVisible(line.replace(/[ ]+/g, ' '), limitChars))
      .filter(Boolean)
      .join(NL);
  }
  const inputLines = [src.trim()];
  const lines = [];
  const pushLine = (line) => {
    if (lines.length >= limitLines) return;
    const cleaned = String(line || '').replace(/[ ]+/g, ' ').trim();
    if (!cleaned) return;
    lines.push(pushCopyTakeVisible(cleaned, limitChars));
  };
  inputLines.forEach((line) => {
    if (lines.length >= limitLines) return;
    const words = String(line || '').replace(/[ ]+/g, ' ').trim().split(' ').filter(Boolean);
    if (!words.length) return;
    let cur = '';
    words.forEach((word) => {
      if (lines.length >= limitLines) return;
      const chunks = pushCopySplitLongWord(word, limitChars);
      chunks.forEach((chunk) => {
        if (lines.length >= limitLines) return;
        const next = cur ? cur + ' ' + chunk : chunk;
        if (pushCopyVisibleLen(next) > limitChars && cur) {
          pushLine(cur);
          cur = chunk;
        } else cur = next;
      });
    });
    if (cur && lines.length < limitLines) pushLine(cur);
  });
  return lines.join(NL);
}
function normalizePushCopyInput(raw, maxLines, maxChars) {
  const limitLines = maxLines || PUSH_COPY_MAX_LINES;
  const limitChars = maxChars || PUSH_COPY_MAX_VISIBLE_PER_LINE;
  const src = String(raw || '').replace(/\r/g, '').replace(/\t/g, ' ');
  const normalized = normalizePushCopy(src, limitLines, limitChars);
  if (!normalized) return '';
  if (src.endsWith(' ') && src.indexOf(NL) < 0) {
    const lines = normalized.split(NL);
    const last = lines[lines.length - 1] || '';
    if (lines.length < limitLines || pushCopyVisibleLen(last) < limitChars) return normalized + ' ';
  }
  return normalized;
}
function normalizePushCopyLineInput(raw, maxChars) {
  return pushCopyTakeVisible(String(raw || '').replace(/\r/g, ' ').replace(/\n/g, ' ').replace(/\t/g, ' ').replace(/[ ]+/g, ' '), maxChars || PUSH_COPY_MAX_VISIBLE_PER_LINE);
}
function pushCopyInputMaxLength(maxChars) {
  const n = Math.max(1, Number(maxChars || PUSH_COPY_MAX_VISIBLE_PER_LINE) || PUSH_COPY_MAX_VISIBLE_PER_LINE);
  return n + Math.max(6, Math.ceil(n * 0.6));
}
function buildPushCopyFromInputs(lines, maxChars, maxLines) {
  const out = (lines || [])
    .slice(0, maxLines || PUSH_COPY_MAX_LINES)
    .map((line) => normalizePushCopyLineInput(line, maxChars))
    .filter(Boolean);
  return out.join(NL);
}
function buildPushCopyFromLineInputs(top, bottom, maxChars) {
  const line1 = normalizePushCopyLineInput(top, maxChars);
  const line2 = normalizePushCopyLineInput(bottom, maxChars);
  if (!line1 && !line2) return '';
  if (!line2) return line1;
  return line1 + NL + line2;
}
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', ...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>);
}
if (!window.AddTextBadge) window.AddTextBadge = AddTextBadge; // 550/H1 공용 배지가 있으면 덮어쓰지 않는다.

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));
async function detectImageKind(src) {
  // 누끼(컷) vs 화보(사진) 판별 — 550판 그대로(정교). 기준 = "실제 투명(알파) 있으면 누끼, 불투명이면 화보".
  //  · JPEG은 알파 채널 불가 → 무조건 화보(빠른 경로).
  //  · 스무딩 OFF로 얇은 투명 테두리 보존 + 256px 다운샘플 + 임계 0.3% → 꽉 찬 누끼(투명 여백 적음)도 잡힘.
  //    (기존 48px·스무딩ON·2%는 꽉 찬 인물 누끼를 화보로 오판했음.)
  try {
    if (/^data:image\/jpe?g/i.test(src) || /\.jpe?g(?:[?#]|$)/i.test(src)) return 'photo';  // JPEG → 화보(알파 불가)
  } catch (e) {}
  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';}
}
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('');
}

// ── 1E 렌더러: 우측 이미지존(550 재사용 배치) + 배경/좌측 텍스트/버튼 전체 합성 ──
function Banner1E({ cand, main, sub, chip, textColor, textStyle, scale = 1, gradRatio, gradOpacity, gradient: gradientOverride, backgroundExtend, imageAdjust, imageAdjustBySource, rects: editedRects, onRectsChange, selectedId, onSelectRect, onBeginEdit, scorer, onDeleteRect, onSelectionChange }) {
  const bgRef = useSR2(null);
  const frontRef = useSR2(null);
  const editable = !!onRectsChange;
  const rects = (editedRects && editedRects.length ? editedRects : (cand && cand.rects)) || [];
  useSE2(() => {
    if (!bgRef.current || !cand || !window.AP1E_TOP) return;
    const rd = cand._render || {};
    const bg = rd.bgColor || chip;              // 화보형=톤 자동 / 누끼형=컬러칩
    // gradientOverride: 라디오 명시 선택이면 그 값 사용. undefined면 후보 _render.gradient 폴백.
    const useGrad = gradientOverride !== undefined ? gradientOverride : !!rd.gradient;
    const spec = {
      type: rd.type || 'nukki', bgColor: bg, rects,
      skipImages: false,
      gradient: useGrad, gradientColor: bg,
      backgroundExtend: !!backgroundExtend,
      imageAdjust, imageAdjustBySource,
      gradientRatio: gradRatio, gradientOpacity: gradOpacity,   // 고급설정 조절값
      text: { main, sub, buttonLabel: '더 보러가기' }, textColor,
      textStyle,
      scale: Math.max(2, Math.round(scale * 2)),
      preview: true,   // 미리보기 → 빈 슬롯 점선 표시 (저장 PNG엔 preview 없음 → 점선 안 나옴)
    };
    // 폰트 로딩을 기다렸다가 '한 번만' 렌더 (즉시+지연 이중 렌더 경합 제거). renderBanner 내부 토큰과 병행.
    let cancelled = false;
    const draw = async () => {
      try { if (document.fonts && document.fonts.ready) await document.fonts.ready; } catch (e) {}
      if (cancelled) return;
      if (editable) {
        if (bgRef.current) {
          const b = { ...spec, layer: 'back', skipImages: true };
          window.AP1E_TOP.renderBanner(bgRef.current, b);
        }
        if (!cancelled && frontRef.current) {
          const fr = { ...spec, layer: 'front' };
          window.AP1E_TOP.renderBanner(frontRef.current, fr);
        }
      } else if (bgRef.current) {
        window.AP1E_TOP.renderBanner(bgRef.current, spec);
      }
    };
    draw();
    return () => { cancelled = true; };
  }, [cand && cand.id, cand && JSON.stringify(cand.rects), editable, JSON.stringify(rects), main, sub, chip, textColor, JSON.stringify(textStyle || {}), scale, gradRatio, gradOpacity, gradientOverride, backgroundExtend, JSON.stringify(imageAdjust || {}), JSON.stringify(imageAdjustBySource || {})]);
  const CW = window.AP1E_TOP ? window.AP1E_TOP.CW : 360;
  const CH = window.AP1E_TOP ? window.AP1E_TOP.CH : 100;
  const z = window.AP1E_TOP ? window.AP1E_TOP.zones({}) : { imgX: 220, imgW: 140 };
  const dispW = CW * scale;
  const stageStyle = { position: 'absolute', left: 0, top: 0, width: dispW + 'px', height: (CH * scale) + 'px' };
  const editStageProps = {
    rects, dispW, cw: CW, ch: CH, imgZone: z, bounds: cand && cand.bounds, scorer,
    selectedId, onSelect: onSelectRect, onChange: onRectsChange, onBegin: onBeginEdit,
    noReflow: true, onDeleteItem: onDeleteRect, imageAdjust, imageAdjustBySource, onSelectionChange,
  };
  return <div style={{ position: 'relative', width: dispW + 'px', height: (CH * scale) + 'px', border: '1px solid #e7e7ee', boxSizing: 'border-box' }}>
    <canvas data-onee-layer="back" ref={bgRef} style={{ ...stageStyle, display: 'block', zIndex: 1 }} />
    {editable && <div data-onee-layer="stage" style={{ ...stageStyle, zIndex: 3 }}>
      <PushEditStage {...editStageProps} foreground={<canvas data-onee-layer="front" ref={frontRef} style={{ ...stageStyle, display: 'block', zIndex: 1, pointerEvents: 'none' }} />} />
    </div>}
  </div>;
}

// 후보 랭킹 썸네일 — 자동배치(이미지 존)만 (텍스트/버튼 없이 배치 결과만)
function PlacementThumb({ cand, chip, scale = 1 }) {
  const ref = useSR2(null);
  useSE2(() => {
    if (!ref.current || !cand || !window.AP1E_TOP) return;
    const rd = cand._render || {};
    window.AP1E_TOP.renderPlacement(ref.current, { type: rd.type || 'nukki', bgColor: rd.bgColor || chip, rects: cand.rects || [], scale: Math.max(2, Math.round(scale * 2)) });
  }, [cand && cand.id, cand && JSON.stringify(cand.rects), chip, scale]);
  const zw = (window.AP1E_TOP ? window.AP1E_TOP.zones({}).imgW : 140) * scale;
  const zh = (window.AP1E_TOP ? window.AP1E_TOP.CH : 100) * scale;
  return <canvas ref={ref} style={{ width: zw + 'px', height: zh + 'px', border: '1px solid #e7e7ee', display: 'block' }} />;
}

function Metrics1E({ cand }) {
  if (!cand) return null;
  const rows = [
  ['적합도 점수', cand.scoring ? cand.scoring.score.toFixed(2) : '-'],
  ['배열', cand.rowsLabel || cand.strategyLabel || '-'],
  ['채움(coverage)', cand.coverage != null ? Math.round(cand.coverage * 100) + '%' : '-'],
  ['실루엣', cand.silhouette != null ? Number(cand.silhouette).toFixed(2) : '-'],
  ['제품 수', (cand.rects || []).length]];

  return (
    <div style={{ border: '1px solid #ececf2', borderTop: 'none', borderRadius: '0 0 8px 8px', padding: '10px 12px', fontSize: '12px', color: '#43434e', background: '#fafafb' }}>
      {rows.map(([k, v]) => <div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0' }}><span style={{ color: '#8a8a96' }}>{k}</span><b style={{ color: '#1c1c22' }}>{v}</b></div>)}
    </div>);

}

// 대표색 → 컬러칩(9색) 중 색상 계열이 가장 가까운 칩 (저채도는 중립 그레이)
function _hexToRgb1E(hex) { const h = String(hex).replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
function _hsl1E(rgb) {
  const r = rgb[0] / 255, g = rgb[1] / 255, b = rgb[2] / 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
  let h = 0; if (d) { if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h *= 60; if (h < 0) h += 360; }
  const l = (mx + mn) / 2, s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
  return [h, s, l];
}
function nearestColorchip1E(hex) {
  const chips = window.COLORCHIP_1E_TOP || [];
  if (!chips.length) return null;
  const hs = _hsl1E(_hexToRgb1E(hex));
  // 무채색 처리는 호출부(대표색 null)에서 함 → 여기선 hue로만 매칭 (평균으로 채도 떨어져도 회색 오분류 안 함)
  let best = null, bd = Infinity;
  chips.forEach((c) => { const cc = _hsl1E(_hexToRgb1E(c.hex)); if (cc[1] < 0.12) return; let dh = Math.abs(hs[0] - cc[0]); if (dh > 180) dh = 360 - dh; if (dh < bd) { bd = dh; best = c.hex; } });
  return best || chips[0].hex;
}
function _hueDist1E(a, b) { let d = Math.abs(a - b); return d > 180 ? 360 - d : d; }
function _clamp1E(v, min, max) { return Math.max(min, Math.min(max, v)); }
// 이미지에서 '선명한(채도 있는)' 대표색 추출 — 채도 가중 hue 히스토그램. 무채색 위주면 null.
function dominantVivid1E(src) {
  return new Promise((resolve) => {
    const img = new Image(); img.crossOrigin = 'anonymous';
    img.onerror = () => resolve(null);
    img.onload = () => {
      try {
        const W = 64, H = Math.max(1, Math.round(64 * 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 acc = {}; let vivid = 0;
        for (let i = 0; i < d.length; i += 4) {
          if (d[i + 3] < 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), dl = mx - mn;
          if (mx > 245 && mn > 238) continue;                 // 흰색 제외
          const l = (mx + mn) / 510; const s = dl === 0 ? 0 : (dl / 255) / (1 - Math.abs(2 * l - 1));
          if (s < 0.08) continue;                             // 무채색 제외(파스텔·연한 색도 잡게 완화)
          vivid++;
          const hs = _hsl1E([r, g, b]); const bin = Math.floor(hs[0] / 20) % 18;
          const e = acc[bin] || (acc[bin] = { w: 0, r: 0, g: 0, b: 0 });
          e.w += s; e.r += r * s; e.g += g * s; e.b += b * s;
        }
        if (vivid < (d.length / 4) * 0.008) { resolve(null); return; }   // 선명한 픽셀 거의 없음 → 무채색
        let bk = null; for (const k in acc) { if (!bk || acc[k].w > bk.w) bk = acc[k]; }
        resolve('#' + [bk.r / bk.w, bk.g / bk.w, bk.b / bk.w].map((v) => Math.round(v).toString(16).padStart(2, '0')).join(''));
      } catch (e) { resolve(null); }
    };
    img.src = src;
  });
}
// 화보(사진) 배경 톤 = 실제 배너에서 배경과 맞닿는 좌측 연결부 평균색. (피사체색 아님)
function photoBgColor1E(src) {
  return new Promise((resolve) => {
    const img = new Image(); img.crossOrigin = 'anonymous';
    img.onerror = () => resolve(null);
    img.onload = () => {
      try {
        const W = 64, H = Math.max(1, Math.round(64 * 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 m = Math.max(2, Math.round(Math.min(W, H) * 0.16));   // 테두리 두께
        let r = 0, g = 0, b = 0, n = 0;
        for (let y = 0; y < H; y++) for (let xx = 0; xx < W; xx++) {
          if (!(xx < m || xx >= W - m || y < m || y >= H - m)) continue;   // 테두리만
          const i = (y * W + xx) * 4; if (d[i + 3] < 128) continue;
          r += d[i]; g += d[i + 1]; b += d[i + 2]; n++;
        }
        if (!n) { resolve(null); return; }
        resolve('#' + [r / n, g / n, b / n].map((v) => Math.round(v).toString(16).padStart(2, '0')).join(''));
      } catch (e) { resolve(null); }
    };
    img.src = src;
  });
}
function photoConnectorStats1E(src) {
  return new Promise((resolve) => {
    const img = new Image(); img.crossOrigin = 'anonymous';
    img.onerror = () => resolve(null);
    img.onload = () => {
      try {
        const iw = img.naturalWidth || img.width || 1, ih = img.naturalHeight || img.height || 1;
        const z = window.AP1E_TOP && window.AP1E_TOP.zones ? window.AP1E_TOP.zones({}) : { imgW: 140 };
        const targetW = z.imgW || 140, targetH = window.AP1E_TOP ? window.AP1E_TOP.CH : 100;
        const scale = Math.max(targetW / iw, targetH / ih);
        const sw = targetW / scale, sh = targetH / scale;
        const sx = Math.max(0, (iw - sw) / 2), sy = Math.max(0, (ih - sh) / 2);
        const W = 96, H = 84;
        const c = document.createElement('canvas'); c.width = W; c.height = H;
        const x = c.getContext('2d'); x.drawImage(img, sx, sy, sw, sh, 0, 0, W, H);
        const d = x.getImageData(0, 0, W, H).data;
        const band = Math.max(8, Math.round(W * 0.22));
        let r = 0, g = 0, b = 0, n = 0, vivid = 0;
        for (let yy = 0; yy < H; yy++) for (let xx = 0; xx < band; xx++) {
          const i = (yy * W + xx) * 4; if (d[i + 3] < 128) continue;
          const rr = d[i], gg = d[i + 1], bb = d[i + 2];
          const mx = Math.max(rr, gg, bb), mn = Math.min(rr, gg, bb);
          if (mx > 248 && mn > 244) continue;
          r += rr; g += gg; b += bb; n++;
          const hs = _hsl1E([rr, gg, bb]); if (hs[1] > 0.12) vivid++;
        }
        if (!n) { resolve(null); return; }
        const rgb = [r / n, g / n, b / n].map((v) => Math.round(v));
        const hsl = _hsl1E(rgb);
        resolve({ rgb, hex: '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join(''), h: hsl[0], s: hsl[1], l: hsl[2], vividRatio: vivid / n, temp: (rgb[0] - rgb[2]) / 255 });
      } catch (e) { resolve(null); }
    };
    img.src = src;
  });
}
function bestPhotoColorchip1E(src) {
  return photoConnectorStats1E(src).then((st) => {
    const chips = window.COLORCHIP_1E_TOP || [];
    if (!chips.length) return null;
    const gray = (chips.find((c) => c.id === 'color_09') || chips[chips.length - 1]).hex;
    const warmNeutral = (chips.find((c) => c.id === 'color_08') || {}).hex || gray;
    if (!st) return gray;
    if (st.s < 0.10 || st.vividRatio < 0.08) return st.temp > 0.08 ? warmNeutral : gray;
    const targetS = _clamp1E(st.s * 0.80 + 0.05, 0.12, 0.55);
    const targetL = _clamp1E(st.l + (st.l < 0.38 ? 0.18 : st.l > 0.72 ? -0.10 : 0.04), 0.42, 0.72);
    let best = chips[0], bestScore = Infinity;
    chips.forEach((c) => {
      const hs = _hsl1E(_hexToRgb1E(c.hex));
      const hue = _hueDist1E(st.h, hs[0]) / 180;
      const sat = Math.abs(hs[1] - targetS);
      const light = Math.abs(hs[2] - targetL);
      const tooLoud = Math.max(0, hs[1] - targetS - 0.25);
      const neutralMiss = st.s > 0.18 && hs[1] < 0.08 ? 0.2 : 0;
      const score = hue * 0.72 + sat * 0.22 + light * 0.30 + tooLoud * 0.2 + neutralMiss;
      if (score < bestScore) { bestScore = score; best = c; }
    });
    return best.hex;
  });
}

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

// 고급설정 기본값 — 단일 출처(초기 state + 새 이미지 리셋에서 공용). 자체 기본값(overlapTol 0.1) 보존.
const DEFAULT_BG_MODE = 'png';
const DEFAULT_SCORER_STATE = { targetDensity: 1, overlapTol: 0.1, siloMode: 'A', expFloor: 0.25 };
const DEFAULT_GRAD_OPACITY = 1;
const DEFAULT_ONE_E_GRADIENT_BG = '#ffffff';
const STRICT_NUKKI_QUALITY_PASSES = 5;
function mergePushRenderControls(form, controls) {
  const base = { ...(form || {}) };
  if (!controls) return base;
  return {
    ...base,
    gradient: controls.bgStyle === 'gradient',
    gradientRatio: controls.gradRatio,
    gradientOpacity: controls.gradOpacity,
  };
}
function AutoPlaceTyped({ profile, initialType, initialImages, initialRecords, initialCategory = null, restore, onRestoreConsumed, ga4Source = 'edit' } = {}) {
  // ── 공유 팩토리: profile 이 없으면 전부 1E 기본값 → 기존 1E 동작 그대로(무회귀).
  //    유형별(앱푸시 등)은 profile 로 렌더러·인스펙터·엔진·스키마·유형만 갈아끼운다. 엔진/틀 파일은 안 건드림. ──
  const P = profile || {};
  const Banner = P.Banner || Banner1E;          // 유형별 렌더러
  const Thumb = P.Thumb || PlacementThumb;      // 유형별 후보 썸네일
  const BT = P.bannerTypes || BANNER_TYPES;     // 유형 목록
  const SCH = P.schema || window.SCHEMA_1E_TOP; // 스키마(틀) — 읽기만
  const ENG = P.engine || window.AP1E_TOP;      // 컴포지터 엔진 — 읽기만
  const TITLE = P.title || '1E 기술서상단';
  const isPush = TITLE === '앱푸시';
  // 1E도 H1처럼 등록 상품 목록에서 선택해 배치할 수 있어야 한다.
  const enableImageLibrary = !P.title || TITLE === '앱푸시' || TITLE === '1E 기술서상단';
  const ga4Src = ga4Source === 'asst' ? 'asst' : 'edit';
  const PNG_LABEL = P.pngLabel || 'PNG 저장 (360×100)';
  // 외부(레지스트리)에서 넘긴 initialType 이 유형 목록(BT)에 있으면 그걸 초기 유형으로. 없으면 profile 기본.
  const _initType = (initialType && BT.some((t) => t.id === initialType)) ? initialType : (P.initialType || 'A');
  const [bannerType, setBannerType] = useS2(_initType);
  const [form, setForm] = useS2(() => (P.initialForm ? { ...P.initialForm } : {})); // 유형별 인스펙터 상태(앱푸시=로고/문구/유형필드)
  const previewForm = useDeferred2(form);
  const patchForm = (o) => setForm((s) => ({ ...s, ...o }));
  useSE2(() => { setForm((s) => ({ ...s, type: bannerType, ...(bannerType === 'airtime' && !s.badge ? { badge: 'LIVE' } : {}) })); }, [bannerType]);   // 유형을 form에 싣기(+방송시간이면 배지 기본 LIVE)
  const [typeAuto, setTypeAuto] = useS2(true);   // 유형 자동전환(콘텐츠 기반). 사용자가 드롭다운 조작하면 해제
  // 배경 스타일: 'gradient'=그라데이션 / 'solid'=단색형. 초기값은 유형 기본(화보=그라데이션, 누끼=단색).
  const [bgStyle, setBgStyle] = useS2(isPush || _initType === 'editorial' ? 'gradient' : 'solid');
  const [bgStyleManual, setBgStyleManual] = useS2(false);   // 사용자가 라디오로 명시 선택했으면 true → 탭 전환 시 초기화 안 함
  // 누끼/화보 탭 수동 선택: typeAuto 비활성화 + setBannerType 동시 처리
  const chooseBannerType = (id) => { setTypeAuto(false); setBannerType(id); };
  // 등록 이미지에서 진입하면 그 상품의 커머스 카테고리로 시작(수동 확정) — 없으면 기존 자동 감지.
  const [category, setCategory] = useS2(initialCategory);
  const [catAuto, setCatAuto] = useS2(!initialCategory);
  const recordsBySrc = useSM2(() => {
    const cm = typeof window !== 'undefined' && window.__catalogMeta;
    return cm ? cm.buildRecordsBySrc(initialRecords) : {};
  }, [initialRecords]);
  // ── 1E 전용 상태 (텍스트·배경·파일명·적합도 접기) ──
  const [main1e, setMain1e] = useS2('여름 역시즌 ~69%');
  const [sub1e, setSub1e] = useS2('미리 준비하는 특가');
  const [chip1e, setChip1e] = useS2((window.COLORCHIP_1E_TOP && window.COLORCHIP_1E_TOP[1] && window.COLORCHIP_1E_TOP[1].hex) || '#F3AF7F');
  const [photoBg1e, setPhotoBg1e] = useS2(DEFAULT_ONE_E_GRADIENT_BG);
  const [chipAuto, setChipAuto] = useS2(true);   // 1E 그라데이션은 기본 배경을 쓰고, 누끼 단색만 대표색 칩을 자동선택한다.
  // 화보형 그라데이션 조절. 폭=이미지존 폭 배율 / 진하기=전체 알파 배율
  const _oneEGradRatio0 = (SCH && SCH.imageZone && SCH.imageZone.gradientRatio != null) ? SCH.imageZone.gradientRatio : 0.6;
  const _gradRatio0 = isPush ? 1.08 : _oneEGradRatio0;
  const gradRatioMax = isPush ? 1.6 : 1;
  const [gradRatio, setGradRatio] = useS2(_gradRatio0);
  const [gradOpacity, setGradOpacity] = useS2(DEFAULT_GRAD_OPACITY);
  const [bgExtend1e, setBgExtend1e] = useS2(true);
  const [imageAdjust, setImageAdjust] = useS2(() => Object.assign({}, (window.BannerImageTools && window.BannerImageTools.DEFAULTS) || {}));
  const [imageAdjustBySource, setImageAdjustBySource] = useS2({});
  const [outpaintVariants, setOutpaintVariants] = useS2([]);
  const [textColor1e, setTextColor1e] = useS2('#333333');
  const [mainSize1e, setMainSize1e] = useS2(21);
  const [subSize1e, setSubSize1e] = useS2(12);
  const [metaOpen1e, setMetaOpen1e] = useS2(false);
  const [saveOpen, setSaveOpen] = useS2(false);   // 저장 팝업(550식)
  const _in1e = { width: '100%', border: '1px solid #e7e7ee', borderRadius: '8px', padding: '7px 9px', font: 'inherit', fontSize: '12.5px', marginTop: '4px', boxSizing: 'border-box' };
  const [srcs, setSrcs] = useS2(() => Array.isArray(initialImages) ? initialImages : []); // 단일 업로드 (상품+모델 혼합)
  const [allItems, setAllItems] = useS2([]); // 준비된 아이템 전체
  const [busy, setBusy] = useS2(false);
  const [catBusy, setCatBusy] = useS2(false);
  const [faceData, setFaceData] = useS2({}); // {src: {head, chin, person}} — 패션 얼굴 정렬·인물 그룹
  const [headChinData, setHeadChinData] = useS2(() => {try {return JSON.parse(localStorage.getItem('bannerly_facemarks') || '{}');} catch (e) {return {};}}); // 사용자 지정 머리끝/턱끝 (비율)
  const [editFaceSrc, setEditFaceSrc] = useS2(null);
  const saveFaceMark = (src, head, chin) => {setHeadChinData((p) => {const n = { ...p, [src]: { head, chin } };try {localStorage.setItem('bannerly_facemarks', JSON.stringify(n));} catch (e) {}return n;});setEditFaceSrc(null);};
  const faceCacheRef = useSR2({});
  const [toast, setToast] = useS2(null);
  const [pinnedId, setPinnedId] = useS2(null);
  const [restoredCand, setRestoredCand] = useS2(null);
  const [editRects, setEditRects] = useS2({}); // {candId: rects} — 제품 오브젝트 편집 상태
  const resetStateRef = 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 [bgMode, setBgMode] = useS2(DEFAULT_BG_MODE);
  const [scorer, setScorer] = useS2({ ...DEFAULT_SCORER_STATE });
  // 선택사항(우측)
  const [logoSrcs, setLogoSrcs] = useS2([]); // 로고 (독립, 최대 2)
  const [logoMeta, setLogoMeta] = useS2([]); // 로고 메타(비율·가로/세로형) — 캔버스 오버레이 & 예약 높이
  const [exSel, setExSel] = useS2(null); // 택1: 'text'|'image'|'flag'|'mark'
  const [addText, setAddText] = useS2('');
  const [addImgSrc, setAddImgSrc] = useS2(null);
  const [addImgKind, setAddImgKind] = useS2(null); // 'nukki' | 'photo'
  const [addImgPos, setAddImgPos] = useS2({ x: 50, y: 50 }); // 화보 cover 초점(%)
  const [markType, setMarkType] = useS2(null);
  const [addImgType, setAddImgType] = useS2('사은품'); // 부가이미지 유형 택1
  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)
  // 불러오기 탭 — H1/550과 같은 등록 상품 이미지 선택 흐름
  const [imgTab, setImgTab] = useS2('upload'); // 'upload' | 'library'
  const [nukkiSel, setNukkiSel] = useS2([]); // 누끼 편집 대상으로 선택된 이미지 src 목록
  const [libDb, setLibDb] = useS2(null);
  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);
  const previewRef = useSR2(null);   // 미리보기 영역 — 이 밖을 클릭하면 선택(편집 핸들) 해제
  const catalogBlobMapRef = useSR2({}); // originalUrl → blobUrl
  const catalogDlSetRef = useSR2(new Set()); // 다운로드 중인 originalUrl 집합
  const preserveSrcChangeRef = useSR2(false); // 삭제/되돌리기는 현재 좌표와 고급설정을 유지
  const pendingSourceEditRef = useSR2(null); // AI 시안 교체 뒤 새 소스에 기존 좌표를 승계

  const is1ETop = TITLE === '1E 기술서상단';
  const guideCounts1e = is1ETop && SCH && SCH.guideSpec && SCH.guideSpec.counts ? SCH.guideSpec.counts : null;
  const countRuleFor = (cat) => {
    const base = cat && CAT_RULES[cat] || DEFAULT_RULE;
    if (!guideCounts1e) return base;
    return {
      ...base,
      prodMax: guideCounts1e.productSmallMax || 5,
      modelMax: guideCounts1e.peopleMax || 3,
      productDefaultMax: guideCounts1e.productDefaultMax || 3,
      peopleDefault: guideCounts1e.peopleDefault || 2,
    };
  };
  const rule = useSM2(() => countRuleFor(category), [category, guideCounts1e]);
  const showToast = (m) => {setToast(m);setTimeout(() => setToast(null), 2400);};
  const resetGlobalImageAdjust = () => setImageAdjust(Object.assign({}, (window.BannerImageTools && window.BannerImageTools.DEFAULTS) || {}));
  const readAsDataURL = (file) => new Promise((res) => {const r = new FileReader();r.onload = () => res(r.result);r.readAsDataURL(file);});
  const filesToUrls = async (files) => Promise.all(Array.from(files || []).filter((f) => f.type.startsWith('image/')).map(readAsDataURL));
  const addImages = async (files) => {const u = await filesToUrls(files);if (u.length) {resetGlobalImageAdjust();setSrcs((p) => [...p, ...u]);}};
  const toggleCatalogUrl = (url) => {
    const r = countRuleFor(category);
    const maxLen = r.prodMax + r.modelMax;
    const existingBlob = catalogBlobMapRef.current[url];
    if (existingBlob && srcs.includes(existingBlob)) {
      removeSourcesPreservePlacement([existingBlob]);
      delete catalogBlobMapRef.current[url];
      return;
    }
    if (srcs.includes(url)) { removeSourcesPreservePlacement([url]); return; }
    if (catalogDlSetRef.current.has(url)) return;
    if (srcs.length >= maxLen) { showToast(`이미지는 최대 ${maxLen}장까지 추가할 수 있어요`); return; }
    catalogDlSetRef.current.add(url);
    const P2 = window.BannerEditorPersist;
    (P2 ? P2.downloadOriginal(url) : Promise.resolve(null)).then((blobUrl) => {
      catalogDlSetRef.current.delete(url);
      if (!blobUrl) { showToast('이미지 다운로드에 실패했습니다'); return; }
      catalogBlobMapRef.current[url] = blobUrl;
      resetGlobalImageAdjust();
      setSrcs((prev) => {
        if (prev.length >= maxLen) { showToast(`이미지는 최대 ${maxLen}장까지 추가할 수 있어요`); return prev; }
        return [...prev, blobUrl];
      });
    }).catch(() => { catalogDlSetRef.current.delete(url); showToast('이미지 다운로드에 실패했습니다'); });
  };
  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));
  };
  const libCatProducts = useSM2(() => {
    if (!libDb || !category) return [];
    const roots = new Set(catalogRootsForCommerce(category));
    const cats = (libDb.categories || []).filter((c) => {
      const p0 = c.taxonomy && ((c.taxonomy.path && c.taxonomy.path[0]) || c.taxonomy.large);
      return roots.has(p0);
    });
    return cats.flatMap((c) => c.products || []).slice(0, 60);
  }, [libDb, category]);
  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 toThumbUrl = (p) => {
    const src = 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 p.primaryImage || (p.images && p.images[0] && p.images[0].path) || '';
  };
  // 누끼 팝업(window.NukkiPopup) 열기 → 완료 시 누끼(투명PNG)/원본 결과를 그대로 업로드 목록(srcs)에 추가.
  // (기본 bgMode 'png'은 prepareItem에서 재제거 없이 그대로 crop → 팝업의 수동 누끼 보존)
  const openNukkiPopup = () => {
    if (!(window.NukkiPopup && window.NukkiPopup.open)) {showToast('누끼 도구를 불러오지 못했어요 (스크립트 로드 확인)');return;}
    const selectedUploads = nukkiSel.filter((s) => srcs.includes(s));
    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) => {
        let applied = 0, skipped = 0;
        const hasReplacement = (results || []).some((result) => result && result.ok && result.nukki && result.dataUrl);
        if (hasReplacement && editCand) {
          const rects = rectsFor(editCand).map((rect) => ({ ...rect }));
          setEditRects((prev) => ({ ...prev, [editCand.id]: rects }));
          setPinnedId(editCand.id);
          preserveSrcChangeRef.current = true;
        }
        setSrcs((prev) => {
          const next = [...prev];
          (results || []).forEach((r, i) => {
            const used = r && r.ok && r.nukki && r.dataUrl;
            if (!used) { skipped++; return; }
            const origSrc = imagesToSend[i];
            const idx = next.indexOf(origSrc);
            if (idx !== -1) next[idx] = used;
            else if (!next.includes(used)) next.push(used);
            applied++;
          });
          return next;
        });
        setNukkiSel([]);
        if (applied) {
          patchForm({ imageType: 'nukki' });
          setBgMode('png');
        }
        if (applied && skipped) showToast('완료된 누끼 이미지만 반영했습니다');
        else if (applied) showToast('누끼 이미지를 반영했습니다');
        else if (skipped) showToast('누끼 생성이 완료되지 않아 원본을 유지했습니다');
        if (window.__ga4 && window.__ga4.trackNukki) window.__ga4.trackNukki(ga4Src);
      },
      onCancel: () => {} });
  };
  const addLogos = async (files) => {const u = await filesToUrls(files);if (u.length) {preserveCurrentPlacementForOverlay();setLogoSrcs((p) => [...p, ...u].slice(0, 2));}};
  const applyLibraryModels = (assets) => {resetGlobalImageAdjust();setSrcs((prev) => Array.from(new Set(prev.concat((assets || []).map((a) => a.src).filter(Boolean)))).slice(0, rule.prodMax + rule.modelMax));};
  const applyLibraryLogos = (assets) => {preserveCurrentPlacementForOverlay();setLogoSrcs((prev) => Array.from(new Set(prev.concat((assets || []).map((a) => a.src).filter(Boolean)))).slice(0, 2));};
  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 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 cloneItemList = (list) => (list || []).map((it) => Object.assign({}, it));
  const addSourceKeys = (keys, value) => { if (value) keys.add(value); };
  const itemMatchesSources = (it, keys) => !!it && (keys.has(it.src) || keys.has(it.url));
  const rectMatchesSources = (r, keys) => !!r && (keys.has(r.src) || keys.has(r.url));
  const snapshotRemoval = (keys) => {
    const cand = editCand;
    if (!cand) return false;
    const before = rectsFor(cand);
    const nextRects = before.filter((r) => !rectMatchesSources(r, keys));
    const nextCand = { ...cand, rects: nextRects, _preservedLayout: bannerType, strategyLabel: '현재 배치 유지' };
    setHistory((h) => [...h.slice(-60), { id: cand.id, rects: before, srcs: srcs.slice(), allItems: cloneItemList(allItems), nukkiSel: nukkiSel.slice(), pinnedId: primaryId || cand.id, restoredCand }]);
    setRedo([]);
    setRestoredCand(nextCand);
    setEditRects((p) => ({ ...p, [cand.id]: nextRects }));
    setPinnedId(cand.id);
    return true;
  };
  const removeSourcesPreservePlacement = (targets) => {
    const keys = new Set();
    (targets || []).forEach((target) => {
      addSourceKeys(keys, target);
      const item = allItems.find((it) => it && (it.src === target || it.url === target));
      if (item) { addSourceKeys(keys, item.src); addSourceKeys(keys, item.url); }
    });
    if (!keys.size) return;
    const srcsWillChange = srcs.some((s) => keys.has(s));
    if (snapshotRemoval(keys) && srcsWillChange) preserveSrcChangeRef.current = true;
    setSrcs((prev) => prev.filter((s) => !keys.has(s)));
    setNukkiSel((prev) => prev.filter((s) => !keys.has(s)));
    setAllItems((prev) => prev.filter((it) => !itemMatchesSources(it, keys)));
    Object.keys(catalogBlobMapRef.current || {}).forEach((url) => {
      if (keys.has(url) || keys.has(catalogBlobMapRef.current[url])) delete catalogBlobMapRef.current[url];
    });
    setSelectedId(null);
  };
  const removeSrc = (i) => {
    const target = srcs[i];
    if (target) removeSourcesPreservePlacement([target]);
  };
  const removeRectSource = (rect) => {
    const r = rect || {};
    const keys = new Set();
    addSourceKeys(keys, r.src); addSourceKeys(keys, r.url);
    const item = allItems.find((it) => itemMatchesSources(it, keys) || it.src === r.url || it.url === r.src);
    if (item) { addSourceKeys(keys, item.src); addSourceKeys(keys, item.url); }
    removeSourcesPreservePlacement(Array.from(keys));
  };
  const clearAll = () => {setSrcs([]);setNukkiSel([]);setAllItems([]);setCategory(null);setCatAuto(true);setPinnedId(null);setRestoredCand(null);setLogoSrcs([]);setExSel(null);setAddText('');setAddImgSrc(null);setMarkType(null);setAddImgType('사은품');setChipColors([]);setChipHexInput('');setChipAuto(true);setBgStyleManual(false);setGradRatio(_gradRatio0);setGradOpacity(DEFAULT_GRAD_OPACITY);setBgExtend1e(true);setFlagCount(1);setAddImgKind(null);setAddImgPos({ x: 50, y: 50 });catalogBlobMapRef.current = {};catalogDlSetRef.current = new Set();};
  const addGuidePlaceholderImage = () => {
    const guide = SCH && SCH.guidePlaceholderImage;
    const src = guide && guide.src;
    if (!src) { showToast('가이드 이미지를 찾을 수 없습니다'); return; }
    setSrcs((prev) => prev.includes(src) ? prev : [...prev, src]);
    setNukkiSel([]);
    patchForm({ imageType: 'editorial', colorAuto: true });
    showToast('가이드 이미지를 추가했습니다');
  };
  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 pickExclusive = (id) => setExSel((v) => v === id ? null : id);
  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 (!srcs.length) {setAllItems([]);return;}
      setBusy(true);
      void bannerType; // 유형 바뀌면 재준비(화보=keep/누끼=remove)
      try {
        const prepared = [];
        const prepMode = bannerType === 'editorial' ? 'png' : bgMode;   // 화보형=투명 보존(png). 'keep'는 JPEG라 투명→블랙
        let nukkiEngineResults = null;
        const enhancedRemove = (TITLE === '1E 기술서상단' || P.enhancedRemove === true) && prepMode === 'remove';
        if (enhancedRemove && window.NukkiEngine && window.NukkiEngine.removeBackgroundBatch) {
          try {
            nukkiEngineResults = await window.NukkiEngine.removeBackgroundBatch(srcs, {
              strictQuality: true,
              qualityPasses: P.removeQualityPasses || STRICT_NUKKI_QUALITY_PASSES,
              onStatus: (s) => {
                if (s && s.stage === 'quality-check' && s.total > 1) showToast('배경 제거 품질 체크 ' + s.pass + '/' + s.total);
                if (s && s.stage === 'llm-fallback') showToast('내장 누끼 실패 · LLM으로 2차 처리 중');
              },
            });
          } catch (e) {
            nukkiEngineResults = null;
            console.warn('[' + TITLE + '] NukkiEngine 배경제거 실패 → 기존 제거 경로 사용', e);
          }
        }
        const cm = typeof window !== 'undefined' && window.__catalogMeta;
        const cc = typeof window !== 'undefined' && window.__commerceCategory;
        for (let i = 0; i < srcs.length; i++) {
          const engineResult = nukkiEngineResults && nukkiEngineResults[i];
          const preparedSrc = engineResult && engineResult.ok && engineResult.dataUrl ? engineResult.dataUrl : srcs[i];
          const preparedMode = engineResult && engineResult.ok && engineResult.dataUrl ? 'png' : prepMode;
          const _prep = await window.AP550.prepareItem(preparedSrc, preparedMode);
          // 원본 투명도로 사진(화보) vs 컷아웃(누끼) 태깅 — "화보 뒤 + 인물누끼 앞" 라우팅 판정에 사용.
          let _pk = 'nukki'; try { _pk = await detectImageKind(srcs[i]); } catch (e) {}
          let it = { ..._prep, id: 'it' + i, src: srcs[i], photoKind: _pk, nukkiModel: engineResult && engineResult.ok ? engineResult.model : null };
          const rec = recordsBySrc[srcs[i]];
          if (cm && rec) it = cm.applyToItem(it, rec, cc);
          prepared.push(it);
        }
        if (cancelled) return;
        prepared.forEach((it) => {const c = catCacheRef.current[it.src];if (c) it.label = c;});
        setAllItems(prepared);
      } finally {if (!cancelled) setBusy(false);}
    })();
    return () => {cancelled = true;};
  }, [srcs, bgMode, bannerType, recordsBySrc]);

  // 비전 분류 (인물=모델 / 그 외=상품 카테고리)
  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) break;let l = null;try {l = await classifyVision(it.src, recordsBySrc[it.src]);} catch (e) {l = null;}catCacheRef.current[it.src] = l || null;}
      // 취소 여부와 무관하게 캐시된 분류 라벨을 현재 상태에 반영 → 이미지를 "따로따로" 업로드해도 인물(모델) 판정이 유실되지 않음(우하단 배치 조건 충족)
      setAllItems((prev) => prev.map((it) => {const c = it.src ? catCacheRef.current[it.src] : null;return c && c !== it.label ? { ...it, label: c } : it;}));
      if (!cancelled) setCatBusy(false);
    })();
    return () => {cancelled = true;setCatBusy(false);};
  }, [allItems]);

  // 상품/모델 분리 (인물 라벨 = 모델)
  const productItems = useSM2(() => allItems.filter((it) => !isModelLabel(it.label)), [allItems]);
  const modelCountForGradient = useSM2(() => allItems.filter((it) => isModelLabel(it.label)).length, [allItems]);
  // 유형 자동 전환: 이미지 '종류'로 결정 — 사각 사진(불투명)=화보형(그라데이션), 투명 누끼=누끼형.
  // (인물/상품 여부와 무관. 사람 사진 1장도 '사진'일 뿐 화보형은 이미지가 사진일 때.) 수동선택 시 유지.
  useSE2(() => {
    if (P.disableAutoType || !typeAuto || !srcs.length) return;   // 앱푸시 등은 유형체계가 달라 자동전환 끔
    if (is1ETop) {
      if (!allItems.length) return;
      const want = allItems.some((it) => it.photoKind === 'photo') ? 'editorial' : 'A';
      if (want !== bannerType) setBannerType(want);
      return;
    }
    let alive = true;
    (async () => {
      // 이미지 종류 자동판별(550식): 투명 누끼 → 누끼 / 배경 있는 사진 → 화보.
      //   여러 장이어도 하나라도 화보(사진)가 섞여 있으면 '화보'로 취급 → editorial(그라데이션 ON·cover). 전부 누끼면 누끼(클러스터).
      //   (화보 + 인물누끼 조합: 화보를 풀이미지 배경으로 깔고 그라데이션을 넣기 위해 필수.)
      let kind = 'nukki';
      if (srcs.length === 1) { kind = await detectImageKind(srcs[0]).catch(() => 'nukki'); }
      else {
        for (const s of srcs) { let k = 'nukki'; try { k = await detectImageKind(s); } catch (e) {} if (k === 'photo') { kind = 'photo'; break; } }
      }
      if (!alive) return;
      if (P.onAutoKind) { P.onAutoKind(kind, { patchForm }); return; }   // 유형별 반영(앱푸시: form.imageType 세팅)
      const want = kind === 'photo' ? 'editorial' : 'A';
      if (want !== bannerType) setBannerType(want);
    })();
    return () => { alive = false; };
  }, [srcs, allItems, typeAuto]);

  // bgStyle 자동 동기화: 앱푸시는 레이아웃 유형이 아니라 이미지 유형(form.imageType)을 기준으로 한다.
  // (수동 선택 후 탭을 전환해도 선택된 배경 스타일이 유지된다)
  useSE2(() => {
    if (bgStyleManual) return;
    const styleType = TITLE === '앱푸시' ? (form && form.imageType) : (is1ETop && modelCountForGradient > 0 ? 'editorial' : bannerType);
    setBgStyle(styleType === 'editorial' ? 'gradient' : 'solid');
  }, [bannerType, form && form.imageType, TITLE, is1ETop, modelCountForGradient]);

  // 누끼형 배경 컬러칩 자동선택: 업로드 이미지 대표색 → 어울리는 칩(색상 계열). 화보/모델 그라데이션 기본값에는 컬러칩을 덮지 않는다.
  useSE2(() => {
    if (!chipAuto || !srcs.length) return;
    if (is1ETop && (bannerType === 'editorial' || modelCountForGradient > 0)) return;
    let alive = true;
    (async () => {
      try {
        const gray = ((window.COLORCHIP_1E_TOP || []).find((c) => c.id === 'color_09') || {}).hex;
        // 누끼 단색 배경만 제품 선명색 → 가장 비슷한 컬러칩으로 자동 보조한다.
        const hex = await dominantVivid1E(srcs[0]);
        let chip;
        if (hex) { const s = _hsl1E(_hexToRgb1E(hex))[1]; chip = s < 0.1 ? gray : nearestColorchip1E(hex); }
        else chip = gray;
        if (alive && chip) setChip1e(chip);
      } catch (e) {}
    })();
    return () => { alive = false; };
  }, [srcs, chipAuto, bannerType, is1ETop, modelCountForGradient]);
  // 1E 화보/모델 그라데이션 자동값은 고정 흰색이나 컬러칩이 아니라 사진 경계 톤이다.
  useSE2(() => {
    if (!is1ETop) return;
    if (!chipAuto || !srcs.length) { setPhotoBg1e(DEFAULT_ONE_E_GRADIENT_BG); return; }
    if (!(bannerType === 'editorial' || modelCountForGradient > 0 || allItems.some((it) => it.photoKind === 'photo'))) return;
    let alive = true;
    (async () => {
      try {
        const srcItem = allItems.find((it) => it.photoKind === 'photo') || allItems.find((it) => isModelLabel(it.label)) || allItems[0];
        const src = (srcItem && srcItem.src) || srcs[0];
        const st = await photoConnectorStats1E(src);
        const hex = (st && st.hex) || await photoBgColor1E(src);
        if (alive) setPhotoBg1e(normHex(hex) || DEFAULT_ONE_E_GRADIENT_BG);
      } catch (e) {
        if (alive) setPhotoBg1e(DEFAULT_ONE_E_GRADIENT_BG);
      }
    })();
    return () => { alive = false; };
  }, [is1ETop, chipAuto, srcs, allItems, bannerType, modelCountForGradient]);
  // 유형별 자동 색(앱푸시 등): 업로드 이미지의 배경/대표색 → 배경색·그라데이션색 자동 (profile.deriveColors)
  useSE2(() => {
    if (!P.deriveColors || !srcs.length) return;
    let alive = true;
    (async () => {
      try {
        // 업로드 중 화보(사진)가 있으면 그 화보 기준으로 색(=그라데이션 톤) 도출 → 스크림이 화보와 이어짐. 없으면 첫 이미지.
        const photoItem = allItems.find((x) => x.photoKind === 'photo');
        const colorSrc = (photoItem && photoItem.src) || srcs[0];
        const patch = await P.deriveColors(colorSrc, { bannerType, form, hasPhoto: !!photoItem });
        if (alive && patch) patchForm(patch);
      } catch (e) {}
    })();
    return () => { alive = false; };
  }, [srcs, allItems, form && form.imageType, form && form.colorAuto]);
  const modelItems = useSM2(() => allItems.filter((it) => isModelLabel(it.label)).map((it) => {const mk = it.src ? headChinData[it.src] : null;const f = it.src ? faceData[it.src] : null;if (mk) return { ...it, headTopFrac: mk.head, faceHFrac: Math.max(0.05, mk.chin - mk.head), personKey: f && f.person };return f ? { ...it, headTopFrac: f.topFrac, faceHFrac: f.faceHFrac != null ? f.faceHFrac * 0.62 : null, personKey: f.person } : it;}), [allItems, faceData, headChinData]);
  const modelItem = useSM2(() => modelItems.length ? { ...modelItems[0], id: '__model' } : null, [modelItems]);
  // 패션 모델 얼굴(정수리·턱) · 인물 식별 감지 → 머리끜·턱끝선 정렬 & 동일 인물 그룹화
  useSE2(() => {
    const models = allItems.filter((it) => isModelLabel(it.label));
    if (!models.length) return;
    const missing = models.filter((it) => it.src && faceCacheRef.current[it.src] === undefined);
    if (!missing.length) return;
    let cancelled = false;
    (async () => {
      for (const it of missing) {
        if (cancelled) return;
        let geo = null,meta = null;
        try {geo = await analyzeModelHead(it.src);} catch (e) {geo = null;}
        try {meta = await detectFaceMeta(it.src);} catch (e) {meta = null;}
        faceCacheRef.current[it.src] = { topFrac: geo && geo.topFrac, faceHFrac: geo && geo.hsFrac, person: meta && meta.person };
      }
      if (cancelled) return;
      setFaceData((p) => {const n = { ...p };missing.forEach((it) => {n[it.src] = faceCacheRef.current[it.src];});return n;});
    })();
    return () => {cancelled = true;};
  }, [allItems]);

  // 카테고리 자동 — 지금 올라온 이미지 "전체" 기준으로 매번 재판단(고착 방지).
  //   · 상품 라벨 다수결로 결정. · 상품이 하나도 없고 모델만일 때만 '패션'. · 상품이 있는데 미분류면 기본값('')로 리셋.
  //   (인물을 먼저 올려 '패션'이 잡혀도, 상품이 들어오면 상품 기준/기본값으로 풀림 → 순서 무관.)
  useSE2(() => {
    if (!catAuto) return;
    const cnt = {};productItems.forEach((it) => {const cc = VISION_TO_COMMERCE[it.label];if (cc) cnt[cc] = (cnt[cc] || 0) + 1;});
    const keys = Object.keys(cnt);
    const top = keys.length ? keys.sort((a, b) => cnt[b] - cnt[a])[0] : null;
    const next = top ? top : ((modelItems.length && !productItems.length) ? '패션' : '');
    if (next !== category) setCategory(next);
  }, [allItems, catAuto]);

  // 개수 제한: 업로드는 절대 삭제하지 않는다(전량 유지). 초과분은 아래 ranked 에서 "배치 후보"에만 제외. 여기선 안내 토스트만.
  useSE2(() => {
    if (!category) return;
    if (productItems.length > rule.prodMax || modelItems.length > rule.modelMax) {
      showToast(`${category} — 배치엔 상품 최대 ${rule.prodMax} · 모델 최대 ${rule.modelMax}장 사용(업로드는 유지)`);
    }
  }, [category, allItems]);

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

  // 로고 비율 로드 → 가로형(h20)/세로형(h60) 판정
  useSE2(() => {
    let cancelled = false;
    if (!logoSrcs.length) {setLogoMeta([]);return;}
    Promise.all(logoSrcs.map((u) => new Promise((res) => {const im = new Image();im.onload = () => res({ url: u, aspect: im.naturalWidth / Math.max(1, im.naturalHeight) });im.onerror = () => res({ url: u, aspect: 2 });im.src = u;}))).then((arr) => {if (!cancelled) setLogoMeta(arr.map((g) => ({ ...g, orient: g.aspect >= 1.6 ? 'h' : 'v' })));});
    return () => {cancelled = true;};
  }, [logoSrcs]);
  // 로고 가이드: 우측 상단 로고 높이만큼(+여백) 콘텐츠 상단을 예약 → 제품/모델과 겹침 방지
  // 로고 가이드 + 부가텍스트 고정위치: 우측 상단 데코 영역(로고 → 아래 30px → 텍스트 배지 90)만큼 상단을 예약
  const logoH = useSM2(() => logoMeta.length ? Math.max.apply(null, logoMeta.map((g) => g.orient === 'h' ? 20 : 60)) : 0, [logoMeta]);
  // 부가정보(택1) 고정위치 데코 — 캔버스 우측 상단(로고 아래 30px)에 렌더 & 예약 (최대 1개, 인증플래그만 최대 2)
  const deco = useSM2(() => {
    if (!exSel) return null;
    if (exSel === 'text') return addText.trim() ? { kind: 'text', w: 90, h: 90, text: addText } : null;
    if (exSel === 'image') return { kind: 'image', w: 130, h: 130, type: addImgType, src: addImgSrc, imgKind: addImgKind, pos: addImgPos };
    if (exSel === 'flag') return { kind: 'flag', w: 90, h: flagCount * 90 + (flagCount - 1) * 10, count: flagCount };
    if (exSel === 'color') {if (!chipColors.length) return null;const shown = chipColors.length > 5 ? 5 : chipColors.length;return { kind: 'color', w: 20, h: shown * 27 + (shown - 1) * 4, colors: chipColors };}
    return null;
  }, [exSel, addText, addImgType, addImgSrc, flagCount, chipColors, addImgKind, addImgPos]);
  const logoReserveTop = useSM2(() => logoH ? logoH + 14 : 0, [logoH]); // 로고만 예약(겹침 금지). 부가정보(이미지 표기 제외)는 모두 상품과 겹침 허용 → 예약 제외
  // 1E: 우측 이미지존에 550 엔진 재사용 배치(autoplace-1e-top.js). 550 파일 무수정.
  const generatedCandidates = useSM2(() => {
    const AP = ENG;
    if (!AP || !allItems.length) return [];
    const sc = { scorer: { ...scorer, logoReserveTop } };
    // 개수 제한을 "배치에서만" 적용: 업로드(srcs)는 그대로 두고, 후보 생성엔 카테고리 규칙 수만큼만 사용(초과분은 배너에 안 그림).
    const capProduct = productItems.slice(0, rule.prodMax);
    const capModel = modelItems.slice(0, rule.modelMax);
    const keepSrc = new Set(capProduct.concat(capModel).map((x) => x.src));
    const capAll = allItems.filter((it) => keepSrc.has(it.src));
    const capModelItem = capModel.length ? { ...capModel[0], id: '__model' } : null;
    // 유형별 후보 생성 훅(앱푸시 등): 템플릿 유형과 무관하게 콘텐츠(상품/모델/사진)로 우측존 배치 결정
    if (P.buildCandidates) {
      const built = P.buildCandidates({ AP, allItems: capAll.length ? capAll : allItems, productItems: capProduct, modelItems: capModel, modelItem: capModelItem, sc, scorer, logoReserveTop, bannerType, form: previewForm }) || [];
      const logoPresent = previewForm && (previewForm.programLogoKind === 'text' ? String(previewForm.programLogoText || '').trim() : previewForm.programLogo);
      const manualOnly = bannerType === 'logo' && capModel.length > 0 && !!logoPresent;
      return manualOnly ? built.map((cand) => ({ ...cand, automaticEligible: false, manualReview: true })) : built;
    }
    // 인물(모델)은 패션 엔진으로 정수리·얼굴 크기 정렬 배치. 상품은 누끼 배치. 사진 단독은 cover-fit.
    if (bannerType === 'editorial') {
      return AP.generateEditorialCandidates(allItems, {}) || [];   // 화보형=사진 cover(전체높이)+그라데이션, 후보랭킹 없음
    }
    if (bannerType === 'A') {
      if (productItems.length) return AP.generateNukkiCandidates(productItems, modelItem, sc) || [];
      if (modelItems.length) return AP.generateFashionCandidates(modelItems, sc) || [];  // 인물은 1명이어도 550식 채움/블리드
      return AP.generateNukkiCandidates(allItems, null, sc) || [];
    }
    return [];
  }, [bannerType, allItems, productItems, modelItems, modelItem, scorer, previewForm && previewForm.imageType, previewForm && previewForm.sticker, rule]);
  const ranked = useSM2(() => {
    const base = generatedCandidates || [];
    if (!restoredCand || restoredCand._preservedLayout !== bannerType) return base;
    return [restoredCand].concat(base.filter((cand) => cand.id !== restoredCand.id));
  }, [generatedCandidates, restoredCand, bannerType]);
  const candidates = ranked;
  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);
  // 앱푸시 고급설정은 공유 1E 상태와 앱푸시 form이 분리되어 있으므로, 렌더 직전에 하나의 스펙으로 합친다.
  const renderForm = TITLE === '앱푸시'
    ? Object.assign(mergePushRenderControls(previewForm, { bgStyle, gradRatio, gradOpacity }), { imageAdjust, imageAdjustBySource })
    : previewForm;
  const primaryHasPhoto = hasPhotoRect(primary);
  const primaryHasModel = is1ETop && hasModelRect(primary);
  const effectiveGradient = bgStyle === 'gradient' || (is1ETop && (primaryHasPhoto || primaryHasModel));
  const primaryNeedsOneEBackdrop = is1ETop && (primaryHasPhoto || primaryHasModel);
  const effectiveBackgroundExtend = !!(primaryNeedsOneEBackdrop && bgExtend1e);
  const oneEUsesAutoGradientBg = is1ETop && effectiveGradient && chipAuto;
  const oneEBgColor = oneEUsesAutoGradientBg ? (photoBg1e || DEFAULT_ONE_E_GRADIENT_BG) : chip1e;
  const oneEChipStatus = chipAuto ? '자동' : '수동';
  const oneEChipApplied = !oneEUsesAutoGradientBg;
  const showGradientControls = isPush ? ((renderForm && renderForm.imageType) !== 'nukki' || primaryHasPhoto || bgStyle === 'gradient') : effectiveGradient;
  // 1E는 우측 인스펙터가 단일 조작 위치다. 앱푸시는 전용 인스펙터에 조절기가 없어 좌측에 유지한다.
  const showLeftGradientControls = showGradientControls && !is1ETop;
  // 패션(의류·모델) 누끼형: 상품 없이 모델만 → 모델 가로 배치 (머리 상단 정렬 · 가장자리 얼굴 세이프 안 · 하단 풀블리드)
  // 1E 모델은 550 좌표 후보를 재사용하면 대표 미리보기와 편집 대상이 서로 다른 rect를 가리킨다.
  // 현재 유형 엔진(1E)의 후보를 그대로 사용해 자동배치·드래그·저장 좌표를 일치시킨다.
  const fashionCands = useSM2(() => {
    const base = bannerType === 'A' && !productItems.length && modelItems.length && ENG && ENG.generateFashionCandidates
      ? ENG.generateFashionCandidates(modelItems, { scorer: { ...scorer, logoReserveTop } }) : [];
    if (!base.length || !restoredCand || restoredCand._preservedLayout !== bannerType) return base;
    return [restoredCand].concat(base.filter((cand) => cand.id !== restoredCand.id));
  }, [bannerType, productItems, modelItems, scorer, ENG, 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;
  const fashionDownload = async () => {if (!fashionCand) return;const cv = document.createElement('canvas');cv.width = 550;cv.height = 550;const ctx = cv.getContext('2d');ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 550, 550);const ordered = [...fashionCand.rects].sort((a, b) => a.z - b.z);for (const r of ordered) {try {const im = await _loadImgD(r.url);drawAdjusted(ctx, im, adjustmentFor(r.src || r.url), r.x, r.y, r.w, r.h);} catch (e) {}}const a = document.createElement('a');a.href = cv.toDataURL('image/png');a.download = '패션모델_누끼.png';a.click();showToast('PNG를 다운로드했습니다 (550×550)');};

  // ── 제품 오브젝트 편집(이동·리사이즈·z순서·undo/redo) — A 누끼형 1순위 카드에서 동작 ──
  const rankOf = (id) => ranked.findIndex((c) => c.id === id) + 1;
  const mergeEditedRects = (c, edited) => {
    if (!c || !edited) return edited || c && c.rects || [];
    const base = c.rects || [];
    const byId = new Map(base.filter((r) => r && r.id != null).map((r) => [r.id, r]));
    return edited.map((r, i) => {
      const b = r && r.id != null ? byId.get(r.id) || base[i] : base[i];
      return b ? Object.assign({}, b, r, { url: b.url || r.url, src: b.src || r.src, role: b.role || r.role, aspect: b.aspect || r.aspect }) : r;
    });
  };
  const rectsFor = (c) => c && editRects[c.id] ? mergeEditedRects(c, editRects[c.id]) : c && c.rects || [];
  const metricsFor = (c) => c && editRects[c.id] ? window.AP550.computeMetrics(rectsFor(c), c && c.bounds) : c ? c.metrics : {};
  const editCand = fashionCand || primary; // 편집 대상 — 패션이면 패션 후보, 아니면 1순위 제품 후보
  const adjustmentFor = (source) => window.BannerImageTools && window.BannerImageTools.forSource
    ? window.BannerImageTools.forSource(imageAdjust, imageAdjustBySource, source)
    : imageAdjust;
  const adjustmentSelectionIds = selectedIds.length ? selectedIds : (selectedId != null ? [selectedId] : []);
  const selectedAdjustmentRects = editCand && rectsFor(editCand).filter((r) => r && adjustmentSelectionIds.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 primaryRects = primary && rectsFor(primary) || [];
  const selectedPrimaryRect = primaryRects.find((rect) => rect && rect.id === selectedId) || null;
  const primaryPhotoRect = primaryRects.find((rect) => rect && (rect.fit === 'cover' || rect.cover || rect.photoKind === 'photo')) || null;
  const outpaintRect = selectedPrimaryRect || primaryPhotoRect || primaryRects[0] || null;
  const outpaintItem = outpaintRect && (allItems.find((item) => item && (item.id === outpaintRect.id || item.src === outpaintRect.src || item.url === outpaintRect.url)) || null);
  const outpaintSource = outpaintItem && outpaintItem.src || outpaintRect && (outpaintRect.src || outpaintRect.url) || srcs[0] || null;
  const applyOutpaintVariant = (nextSrc) => {
    if (!nextSrc || !outpaintSource) return;
    if (editCand) {
      const oldKeys = new Set([outpaintSource]);
      if (outpaintRect) { addSourceKeys(oldKeys, outpaintRect.src); addSourceKeys(oldKeys, outpaintRect.url); }
      if (outpaintItem) { addSourceKeys(oldKeys, outpaintItem.src); addSourceKeys(oldKeys, outpaintItem.url); }
      pendingSourceEditRef.current = {
        cand: { ...editCand },
        rects: rectsFor(editCand).map((rect) => ({ ...rect })),
        oldKeys: Array.from(oldKeys),
        nextSrc,
        bannerType,
      };
    }
    preserveSrcChangeRef.current = true;
    setSrcs((prev) => prev.map((src) => src === outpaintSource ? nextSrc : src));
    setNukkiSel((prev) => prev.map((src) => src === outpaintSource ? nextSrc : src));
    setImageAdjustBySource((prev) => {
      if (!Object.prototype.hasOwnProperty.call(prev, outpaintSource)) return prev;
      const next = { ...prev, [nextSrc]: prev[outpaintSource] };
      delete next[outpaintSource];
      return next;
    });
    setBgMode('keep');
    showToast('선택한 AI 배경 확장 시안을 적용했습니다');
  };
  const preserveCurrentPlacementForOverlay = () => {const cand = editCand;if (!cand || pinnedId) return;setPinnedId(cand.id);};
  const beginEdit = () => {if (!editCand) return;preserveCurrentPlacementForOverlay();pendingRef.current = { id: editCand.id, rects: rectsFor(editCand) };}; // 제스처 스냅샷(실변화 시 커밋)
  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 currentHistorySnapshot = (basis) => {
    const cand = editCand;
    const snap = { id: cand ? cand.id : basis.id, rects: cand ? rectsFor(cand) : [] };
    if (basis && basis.srcs) {
      snap.srcs = srcs.slice();
      snap.allItems = cloneItemList(allItems);
      snap.nukkiSel = nukkiSel.slice();
      snap.pinnedId = primaryId || (cand && cand.id) || null;
      snap.restoredCand = restoredCand;
    }
    return snap;
  };
  const restoreHistorySnapshot = (snap) => {
    if (!snap) return;
    if (snap.srcs) { preserveSrcChangeRef.current = true; setSrcs(snap.srcs.slice()); }
    if (snap.allItems) setAllItems(cloneItemList(snap.allItems));
    if (snap.nukkiSel) setNukkiSel(snap.nukkiSel.slice());
    if (snap.pinnedId !== undefined) setPinnedId(snap.pinnedId);
    if (snap.restoredCand !== undefined) setRestoredCand(snap.restoredCand);
    setEditRects((p) => ({ ...p, [snap.id]: snap.rects || [] }));
    setSelectedId(null);
  };
  const undo = () => {if (!editCand || !history.length) return;const last = history[history.length - 1];setRedo((r) => [...r, currentHistorySnapshot(last)]);restoreHistorySnapshot(last);setHistory((h) => h.slice(0, -1));};
  const redo = () => {if (!editCand || !redoList.length) return;const nx = redoList[redoList.length - 1];setHistory((h) => [...h, currentHistorySnapshot(nx)]);restoreHistorySnapshot(nx);setRedo((r) => r.slice(0, -1));};
  undoRef.current = undo;redoRef.current = redo;
  const resetPrimary = () => {if (!editCand) return;setEditRects((p) => {const n = { ...p };delete n[editCand.id];return n;});setSelectedId(null);};
  const imageZoneForPrimary = () => {
    const zoneOpts = P.buildCandidates ? pushGuidePatternFor(previewForm) : {};
    const z = ENG && ENG.zones ? ENG.zones(zoneOpts) : null;
    return z ? { x: z.imgX || 0, y: z.imgTop || 0, w: z.imgW || ((ENG && ENG.CW) || 0), h: z.imgH || ((ENG && ENG.CH) || 0) } : { x: 0, y: 0, w: (ENG && ENG.CW) || 0, h: (ENG && ENG.CH) || 0 };
  };
  const transformPrimaryRects = (fn) => {
    if (!editCand) return;
    const current = rectsFor(editCand).map((r) => ({ ...r }));
    if (!current.length) return;
    const hasSelected = selectedId && current.some((r) => r.id === selectedId);
    const isTarget = (r) => !hasSelected || r.id === selectedId;
    beginEdit();
    onPrimaryChange(fn(current, isTarget, imageZoneForPrimary()) || current);
  };
  const flipPrimaryRects = () => transformPrimaryRects((current, isTarget) => current.map((r) => isTarget(r) ? { ...r, flipX: !r.flipX } : r));
  const scalePrimaryRects = (factor) => transformPrimaryRects((current, isTarget) => current.map((r) => {
    if (!isTarget(r)) return r;
    const cx = r.x + r.w / 2;
    const cy = r.y + r.h / 2;
    const w = Math.max(24, r.w * factor);
    const h = Math.max(24, r.h * factor);
    return { ...r, x: cx - w / 2, y: cy - h / 2, w, h };
  }));
  const alignPrimaryRects = (axis, pos) => transformPrimaryRects((current, isTarget, zone) => {
    const targets = current.filter(isTarget);
    if (!targets.length) return current;
    let min = Infinity, max = -Infinity;
    targets.forEach((r) => {
      const a = axis === 'x' ? r.x : r.y;
      const b = a + (axis === 'x' ? r.w : r.h);
      min = Math.min(min, a); max = Math.max(max, b);
    });
    const size = Math.max(1, max - min);
    const zoneStart = axis === 'x' ? zone.x : zone.y;
    const zoneSize = axis === 'x' ? zone.w : zone.h;
    const target = pos === 'start' ? zoneStart : pos === 'center' ? zoneStart + (zoneSize - size) / 2 : zoneStart + zoneSize - size;
    const d = target - min;
    return current.map((r) => isTarget(r) ? { ...r, [axis]: r[axis] + d } : r);
  });
  const _loadImgD = (src) => new Promise((res, rej) => {const im = new Image();im.crossOrigin = 'anonymous';im.onload = () => res(im);im.onerror = rej;im.src = src;});
  const saveFileNameRule = (SCH && SCH.fileNameRule) || P.fileNameRule;
  const getGuideValidation = (values) => {
    if (isPush) {
      const logoPresent = form.programLogoKind === 'text' ? !!String(form.programLogoText || '').trim() : !!form.programLogo;
      return window.GuideCompliance.validate({
        guideId: 'ap', schema: SCH, outputCanvas: { w: 720, h: 380 }, enforceContentRules: true,
        templateType: bannerType, form, hasImage: !!primary, gradient: bgStyle === 'gradient',
        modelSellerCombo: bannerType === 'logo' && modelItems.length > 0 && logoPresent,
        manualReviewApproved: !!(values && values.manualReviewApproved),
      });
    }
    return window.GuideCompliance.validate({
      guideId: '1e-top', schema: SCH, outputCanvas: { w: 1080, h: 300 }, enforceContentRules: true,
      layout: bannerType, hasImage: !!primary, mainText: main1e, textColor: textColor1e,
      subOpacity: SCH && SCH.textZone && SCH.textZone.sub && SCH.textZone.sub.opacity,
      gradient: effectiveGradient, backgroundExtend: effectiveBackgroundExtend,
    });
  };
  // 저장 = 스키마 파일명 규칙으로 팝업 오픈. 실제 저장은 SavePopup → save1e.
  const download1e = () => { if (!primary) {showToast('먼저 이미지를 올려 배너를 만들어 주세요');return;} setSaveOpen(true); };
  // primary 후보를 1E 전체합성 PNG blob 으로 렌더
  const _renderBlob1e = async () => {
    if (document.fonts && document.fonts.ready) { try { await document.fonts.ready; } catch (e) {} }
    const cv = document.createElement('canvas');
    const rd = primary._render || {};
    const bg = rd.bgColor || oneEBgColor;
    const saveForm = TITLE === '앱푸시'
      ? Object.assign(mergePushRenderControls(form, { bgStyle, gradRatio, gradOpacity }), { imageAdjust, imageAdjustBySource })
      : form;
    const spec = P.buildSaveSpec
      ? P.buildSaveSpec({ cand: primary, form: saveForm, rd, rects: rectsFor(primary), gradientRatio: gradRatio, gradientOpacity: gradOpacity })
      : { type: rd.type || 'nukki', bgColor: bg, rects: rectsFor(primary), gradient: effectiveGradient, backgroundExtend: effectiveBackgroundExtend, imageAdjust, imageAdjustBySource, gradientColor: bg, gradientRatio: gradRatio, gradientOpacity: gradOpacity, text: { main: main1e, sub: sub1e, buttonLabel: '더 보러가기' }, textColor: textColor1e, textStyle: { main: { size: mainSize1e }, sub: { size: subSize1e } }, scale: 3 };
    await ENG.renderBanner(cv, spec);
    return await new Promise((res) => cv.toBlob(res, P.saveMime || 'image/png', P.saveQuality));
  };
  const _uniqueInDir1e = 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 _dlCount1e = useSR2({});
  // SavePopup 확정 콜백 — 스키마 파일명 규칙 → 폴더 저장(+번호) / 실패 시 다운로드
  const save1e = async (values, dirHandle) => {
    if (!primary || !ENG) return;
    const _rule = saveFileNameRule;
    const built = _rule ? window.buildBannerFileName(_rule, values) : { name: (P.defaultFileName || 'banner') + (P.saveExt || '.png'), missing: [] };
    if (built.missing && built.missing.length) { showToast('필수 미입력: ' + built.missing.join(', ')); return; }
    const blob = await _renderBlob1e();
    if (!blob) { showToast('이미지 생성에 실패했습니다'); return; }
    if (window.__ga4 && window.__ga4.trackDownload) window.__ga4.trackDownload('push', bannerType, (BT.find((t) => t.id === bannerType) || {}).label || bannerType, ga4Src);
    if (dirHandle) {
      try {
        const finalName = await _uniqueInDir1e(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('폴더 저장 실패 — 다운로드로 대체합니다'); }
    }
    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 = _dlCount1e.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);
  const savingDraftRef = useSR2(false);
  const _draftType = () => (BT.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/H1과 동일 로직 + 앱푸시 form 추가) — window.BannerEditorPersist
  const _persistBag = () => ({
    projectCategory: TITLE, category: category, bannerType: bannerType, form: form,
    srcs, logoSrcs, addImgSrc, editCand, editRects,
    exSel, addText, addImgKind, addImgPos, flagCount, chipColors, addImgType, markType,
    scorer, bgMode, catAuto, pinnedId,
    bgStyle, bgStyleManual, bgExtend1e, chip1e, chipAuto,
    main1e, sub1e, textColor1e, mainSize1e, subSize1e, gradRatio, gradOpacity,
    imageAdjust, imageAdjustBySource, outpaintVariants,
    catCache: catCacheRef.current, headChinData, faceData, detectImageKind,
  });
  const collectProject = (withSources) => window.BannerEditorPersist.buildNukkiProject(_persistBag(), withSources);
  const saveDraft = async (auto) => {
    if (!primary) {if (!auto) showToast('저장할 배너가 없습니다');return;}
    if (!window.__myBanners) return;
    if (savingDraftRef.current) {
      dirtyRef.current = true;
      if (!auto) showToast('저장 중입니다');
      return;
    }
    savingDraftRef.current = true;
    try {
      const blob = await _renderBlob1e();
      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: savedRef.current ? 'saved' : 'draft', id: draftIdRef.current, category: TITLE, type: _draftType(), event: '임시저장 ' + _draftType(), previewBlob: blob, width: isPush ? 720 : 1080, height: isPush ? 380 : 300, 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;
        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('저장 실패');}
    finally { savingDraftRef.current = false; }
  };
  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);};
  }, []);

  const downloadPNG = async () => {if (!editCand) return;const rects = rectsFor(editCand);const cv = document.createElement('canvas');cv.width = 550;cv.height = 550;const ctx = cv.getContext('2d');ctx.fillStyle = '#fff';ctx.fillRect(0, 0, 550, 550);const ordered = [...rects].sort((a, b) => a.z - b.z);for (const r of ordered) {try {const im = await _loadImgD(r.url);drawAdjusted(ctx, im, adjustmentFor(r.src || r.url), r.x, r.y, r.w, r.h);} catch (e) {}}const a = document.createElement('a');a.href = cv.toDataURL('image/png');a.download = (editCand.metrics && editCand.metrics.fashion ? '패션모델_누끼' : '상품배너550_A누끼형') + '.png';a.click();showToast('PNG를 다운로드했습니다 (550×550)');};

  // ── 이어서 편집(레시피): props.restore → 공용 로직 복원 + 앱푸시 form 복원. 로직은 window.BannerEditorPersist. ──
  const restoreDoneRef = useSR2(false);
  const pendingRestoreRef = useSR2(null);
  useSE2(() => {
    const rst = restore;
    if (!rst || restoreDoneRef.current) return;
    const BS = window.BannerSnapshot; const snap = rst.snapshot;
    if (!BS || !BS.isValid(snap) || snap.category !== TITLE) 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.form) setForm((s) => Object.assign({}, s, ed.form));    // 앱푸시 필드(문구·배지·유형)
    if (ed.bannerType) { setBannerType(ed.bannerType); setTypeAuto(false); }
    else if (snap.type) { setBannerType(snap.type); setTypeAuto(false); }
    if (ed.scorer) setScorer({ ...ed.scorer });
    if (ed.bgMode) setBgMode(ed.bgMode);
    if (ed.imageAdjust) setImageAdjust(ed.imageAdjust);
    if (ed.bgStyle) { setBgStyle(ed.bgStyle); setBgStyleManual(!!ed.bgStyleManual); }
    if (ed.bgExtend1e != null) setBgExtend1e(!!ed.bgExtend1e);
    if (ed.chip1e) setChip1e(ed.chip1e);
    if (ed.chipAuto !== undefined) setChipAuto(!!ed.chipAuto);
    if (ed.main1e != null) setMain1e(String(ed.main1e));
    if (ed.sub1e != null) setSub1e(String(ed.sub1e));
    if (ed.textColor1e) setTextColor1e(ed.textColor1e);
    if (ed.mainSize1e != null) setMainSize1e(Number(ed.mainSize1e));
    if (ed.subSize1e != null) setSubSize1e(Number(ed.subSize1e));
    if (ed.gradRatio != null) setGradRatio(Number(ed.gradRatio));
    if (ed.gradOpacity != null) setGradOpacity(Number(ed.gradOpacity));
    const restoredCategory = restoredCommerceCategory(ed.category);
    if (ed.category !== undefined) setCategory(restoredCategory);
    if (ed.catAuto !== undefined) setCatAuto(restoredCategory ? ed.catAuto : true);
    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(ov.addImgPos);
    if (ov.flagCount) setFlagCount(ov.flagCount);
    if (Array.isArray(ov.chipColors)) setChipColors(ov.chipColors);
    if (ov.addImgType) setAddImgType(ov.addImgType);
    if (ov.markType !== undefined) setMarkType(ov.markType);
    const logoUrls = (ov.logoRefs || []).map(resolveRef).filter(Boolean);
    if (logoUrls.length) setLogoSrcs(logoUrls);
    const addImgUrl = resolveRef(ov.addImgRef);
    if (addImgUrl) setAddImgSrc(addImgUrl);
    if (Array.isArray(ov.outpaintRefs)) setOutpaintVariants(ov.outpaintRefs.map(resolveRef).filter(Boolean).slice(0, 3));
    const uploads = byRole.upload || [];
    if (Array.isArray(ed.imageAdjustBySource)) {
      const nextAdjust = {};
      uploads.forEach((url, i) => { if (ed.imageAdjustBySource[i]) nextAdjust[url] = ed.imageAdjustBySource[i]; });
      setImageAdjustBySource(nextAdjust);
    }
    window.BannerEditorPersist.seedNukkiCaches(uploads, ed.imageMeta, { catCache: catCacheRef.current, faceCache: faceCacheRef.current, setFaceData: setFaceData, setHeadChinData: setHeadChinData });
    if (uploads.length) setSrcs(uploads);
    showToast('복원 · 이미지 ' + uploads.length + '장');
    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 (onRestoreConsumed) { try { 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]);

  useSE2(() => {
    const pending = pendingSourceEditRef.current;
    if (!pending) return;
    const replacement = allItems.find((item) => item && (item.src === pending.nextSrc || item.url === pending.nextSrc));
    if (!replacement) return;
    const oldKeys = new Set(pending.oldKeys || []);
    let changed = false;
    const rects = pending.rects.map((rect) => {
      if (!rectMatchesSources(rect, oldKeys)) return rect;
      changed = true;
      return { ...rect, id: replacement.id, src: replacement.src, url: replacement.url, aspect: replacement.aspect, alpha: replacement.alpha };
    });
    if (changed) {
      const id = String(pending.cand.id || 'layout') + '-preserved-' + rects.length;
      const nextCand = { ...pending.cand, id, rects, _preservedLayout: pending.bannerType, strategyLabel: '현재 배치 유지', rowsLabel: (pending.cand.rowsLabel || '') + ' · 배치 유지' };
      setRestoredCand(nextCand);
      setEditRects((prev) => ({ ...prev, [id]: rects }));
      setPinnedId(id);
      setSelectedId(replacement.id);
    }
    pendingSourceEditRef.current = null;
  }, [allItems]);

  // 업로드/배경모드/유형 변경 시 편집 이력 초기화. 앱푸시는 유형/배경처리가 부가 옵션이라 좌표를 보존한다.
  useSE2(() => {
    if (pendingRestoreRef.current) return;
    const prev = resetStateRef.current;
    const next = { srcs, bgMode, bannerType };
    resetStateRef.current = next;
    if (preserveSrcChangeRef.current) return;
    const onlyPushOptionChanged = !!(P.buildCandidates && prev && prev.srcs === srcs && (prev.bgMode !== bgMode || prev.bannerType !== bannerType));
    if (onlyPushOptionChanged) { setSelectedId(null); return; }
    setRestoredCand(null); setEditRects({}); setHistory([]); setRedo([]); setSelectedId(null);
  }, [srcs, bgMode, bannerType]);
  // 새 이미지(srcs) 업로드 시 고급설정 값(스코어러·배경처리·그라데이션)을 기본값으로 리셋. 복원 직후엔 저장값을 덮지 않음.
  useSE2(() => {if (pendingRestoreRef.current) return;if (preserveSrcChangeRef.current) { preserveSrcChangeRef.current = false; return; }setScorer({ ...DEFAULT_SCORER_STATE });setBgMode(DEFAULT_BG_MODE);setGradRatio(_gradRatio0);setGradOpacity(DEFAULT_GRAD_OPACITY);setBgExtend1e(true);if (is1ETop) {setChipAuto(true);setBgStyleManual(false);}}, [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);}, []);
  // 미리보기 밖(인스펙터·툴바·페이지 등)을 클릭하면 선택 해제 → 편집 핸들(꼭지) 숨김. 만질 때만 표시.
  useSE2(() => {const onDown = (e) => {if (previewRef.current && previewRef.current.contains(e.target)) return;setSelectedId(null);};window.addEventListener('pointerdown', onDown);return () => window.removeEventListener('pointerdown', onDown);}, []);

  const typeReady = (BT.find((t) => t.id === bannerType) || { ready: true }).ready;
  const required = srcs.length > 0;
  const BG_OPTS = [{ id: 'remove', label: '배경 제거' }, { id: 'png', label: '투명 PNG' }, { id: 'keep', label: '배경 유지' }];
  const labelOf = (i) => {const it = allItems[i];if (!it) return null;return it.label;};

  return (
    <div className="ap550">
      {/* ── 에디터 헤더: 좌 배너이름+정보 / 우 저장하기 ── */}
      <header className="ap-editor-header">
        <div className="ap-eh-left">
          <span className="ap-eh-title">{TITLE}</span>
          {(ranked.length > 0 || productItems.length > 0 || modelItem) && <span className="ap-eh-info">{ranked.length > 0 && <b>{ranked.length}개 후보</b>}{ranked.length > 0 && (productItems.length > 0 || modelItem) && ' · '}{productItems.length > 0 && `상품 ${productItems.length}`}{productItems.length > 0 && modelItem && ' + '}{modelItem && '모델 1'}</span>}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: 'auto' }}>
          {autoMsg && <span style={{ fontSize: 12, color: '#12A150', fontWeight: 600, whiteSpace: 'nowrap' }}>✓ {autoMsg}</span>}
          <button className="ap-eh-save ghost" onClick={() => saveDraft(false)} disabled={!primary} style={!primary ? {opacity:0.45,cursor:'not-allowed'} : {}}>{savedBtn ? '저장됨' : '저장하기'}</button>
          <button className="ap-eh-save" onClick={download1e} disabled={!primary} style={!primary ? {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', font: 'inherit' }}>
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: '7px' }}>
                  {catBusy && !category ? <><TSpinner size={13} /><span style={{ fontSize: '12.5px', color: '#8a8a96' }}>감지 중…</span></> :
                  category ? <><span style={{ fontWeight: 800, fontSize: '13px', color: '#1c1c22' }}>{category}</span><span style={{ fontSize: '10px', fontWeight: 700, padding: '1px 6px', borderRadius: '5px', background: catAuto ? '#eef2ff' : '#f0f0f3', color: catAuto ? '#4E4CDB' : '#8a8a96' }}>{catAuto ? '자동' : '수동'}</span></> :
                  <span style={{ fontSize: '12.5px', color: '#8a8a96' }}>이미지 업로드 시 자동 감지</span>}
                </span>
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8a8a96" strokeWidth="2" strokeLinecap="round"><path d="m6 9 6 6 6-6" /></svg>
              </button>
              {catOpen &&
              <div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 20, background: '#fff', border: '1px solid #e7e7ee', borderRadius: '10px', boxShadow: '0 8px 24px rgba(0,0,0,0.1)', padding: '4px', maxHeight: '260px', overflowY: 'auto' }}>
                  {COMMERCE_CATS.map((c) => { const cr = countRuleFor(c); return <button key={c} className="ap-cat-opt" onClick={() => {setCategory(c);setCatAuto(false);setCatOpen(false);}} style={{ width: '100%', textAlign: 'left', padding: '8px 10px', borderRadius: '7px', border: 'none', cursor: 'pointer', font: 'inherit', fontSize: '12.5px', fontWeight: c === category ? 800 : 500, color: c === category ? '#4E4CDB' : '#1c1c22', background: c === category ? '#eef2ff' : 'transparent', display: 'flex', justifyContent: 'space-between' }}><span>{c}</span><span style={{ fontSize: '10.5px', color: '#9a9aa6', fontWeight: 600 }}>상품 ~{cr.prodMax}{cr.modelMax > 1 ? ' · 모델 ~' + cr.modelMax : ''}</span></button>; })}
                </div>}
            </div>
          </section>

          <section className="ap-sec">
            <div className="ap-sec-head" style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'nowrap' }}>
              <span className="ap-step">2</span>이미지<span className="ap-req">필수</span>
              <span className="ap-count" style={{ marginLeft: 0, marginRight: 0, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{productItems.length}/{rule.prodMax} · 모델 {modelItems.length}/{rule.modelMax}</span>
              {enableImageLibrary && <span style={{ display: 'inline-flex', border: '1.5px solid #e0e0ea', borderRadius: '8px', overflow: 'hidden', flexShrink: 0, marginLeft: 'auto' }}>
                {[{ id: 'upload', label: '이미지 등록' }, { id: 'library', label: '불러오기' }].map((tab) => (
                  <button key={tab.id} type="button" onClick={() => { setImgTab(tab.id); if (tab.id === 'library') ensureLibDb(); }}
                    style={{ padding: '3px 10px', fontSize: '11px', fontWeight: imgTab === tab.id ? 700 : 500, font: 'inherit', border: 'none', cursor: 'pointer', background: imgTab === tab.id ? '#4E4CDB' : 'transparent', color: imgTab === tab.id ? '#fff' : '#6b6b78', transition: 'background .12s,color .12s' }}>
                    {tab.label}
                  </button>
                ))}
              </span>}
            </div>
            {(!enableImageLibrary || imgTab === 'upload') && <>
              {srcs.length === 0 ?
              <div className="ap-drop" onClick={() => upRef.current.click()} onDragOver={(e) => {e.preventDefault();e.currentTarget.classList.add('over');}} onDragLeave={(e) => e.currentTarget.classList.remove('over')} onDrop={(e) => {e.preventDefault();e.currentTarget.classList.remove('over');addImages(e.dataTransfer.files);}}>
                  <div className="ap-drop-ic"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3" /><circle cx="8.5" cy="8.5" r="1.6" /><path d="m21 15-5-5L5 21" /></svg></div>
                  <div className="ap-drop-t">상품·모델 이미지를 올려주세요</div>
                  <div className="ap-drop-h">업로드하면 자동으로 분류돼요</div>
                </div> :
              <>
                  <div className="ap-thumbs">
                    {srcs.map((src, i) => {
                    const lab = labelOf(i);const isM = isModelLabel(lab);
                    return (
                      <div className="ap-thumb" 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' }}>
                          <img src={src} alt="" draggable={false} />
                          {nukkiSel.includes(src) && <span style={{ position: 'absolute', inset: 0, background: 'rgba(78,76,219,0.15)', borderRadius: 'inherit', pointerEvents: 'none' }} />}
                          {nukkiSel.includes(src) && <span style={{ position: 'absolute', right: '4px', bottom: '4px', width: '14px', height: '14px', borderRadius: '50%', background: '#4E4CDB', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none', zIndex: 2 }}><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg></span>}
                          {lab && <span style={{ position: 'absolute', left: '4px', bottom: '4px', padding: '1px 6px', fontSize: '10px', fontWeight: 700, lineHeight: 1.5, color: '#fff', background: isM ? 'rgba(217,131,36,0.95)' : 'rgba(78,76,219,0.92)', borderRadius: '5px', pointerEvents: 'none' }}>{isM ? '모델' : lab}</span>}
                          {catBusy && !lab && <span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(248,248,251,0.66)', borderRadius: 'inherit', pointerEvents: 'none' }}><TSpinner size={16} /></span>}
                          <button type="button" className="ap-thumb-x" 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>
                          {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>);

                  })}
                    <button className="ap-thumb-add" onClick={() => upRef.current.click()} {...dz(addImages)}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg></button>
                    <button onClick={clearAll} title="전체 비우기" className="ap-clear-tile" style={{ aspectRatio: '1', border: '1.5px solid #d8d8e0', borderRadius: '9px', color: '#8a8a96', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '4px', font: 'inherit', fontSize: '10.5px', fontWeight: 700, background: "rgb(244, 244, 244)" }}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>전체 삭제</button>
                  </div>
                  <div className="ap-drophint">인물이면 <b style={{ color: '#D98324' }}>모델</b>(우측 밴드), 없으면 <b style={{ color: '#4E4CDB' }}>상품</b>(좌측 클러스터)으로 자동 분류</div>
                </>}
              <button type="button" onClick={openNukkiPopup} style={{ width: '100%', marginTop: '10px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '7px', border: '1.5px solid #4E4CDB', borderRadius: '10px', background: '#fff', color: '#4E4CDB', font: 'inherit', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><line x1="20" y1="4" x2="8.12" y2="15.88" /><line x1="14.47" y1="14.48" x2="20" y2="20" /><line x1="8.12" y1="8.12" x2="12" y2="12" /></svg>
                누끼 이미지 만들기
              </button>
              {window.AssetLibraryField && <div style={{ marginTop: '8px' }}><AssetLibraryField kind="model" max={rule.modelMax || 3} onApply={applyLibraryModels} /></div>}
            </>}
            {enableImageLibrary && imgTab === 'library' && <div style={{ marginTop: '4px' }}>
              <div style={{ display: 'flex', gap: '6px', marginBottom: '10px' }}>
                <input type="text" value={libMidInput} onChange={(e) => setLibMidInput(e.target.value)}
                  onKeyDown={(e) => { if (e.key === 'Enter') searchByMids(); }}
                  placeholder="MID로 검색 (숫자, 여러 개는 쉼표 또는 공백)"
                  style={{ flex: 1, padding: '8px 10px', fontSize: '12px', border: '1px solid #e0e0ea', borderRadius: '8px', font: 'inherit', outline: 'none', minWidth: 0 }} />
                <button type="button" onClick={searchByMids} disabled={!libDb || !libMidInput.trim()}
                  style={{ padding: '8px 12px', fontSize: '12px', fontWeight: 700, border: 'none', borderRadius: '8px', background: '#4E4CDB', color: '#fff', cursor: libDb && libMidInput.trim() ? 'pointer' : 'default', opacity: libDb && libMidInput.trim() ? 1 : 0.5, font: 'inherit', flexShrink: 0 }}>
                  검색
                </button>
              </div>
              {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 = p.primaryImage || (p.images && p.images[0] && p.images[0].path);
                      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)} 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 = p.primaryImage || (p.images && p.images[0] && p.images[0].path);
                  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)} 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 data-library-selected-panel style={{ position: 'sticky', top: 0, zIndex: 4, margin: '8px 0 10px', padding: '8px', border: '1px solid #dfe3f5', borderRadius: '10px', background: '#fff', boxShadow: '0 4px 10px rgba(19,22,42,.06)' }}>
                <div style={{ fontSize: '11.5px', color: '#43434e', marginBottom: '6px', fontWeight: 700 }}>선택한 이미지 모아보기 {srcs.length}장 — <button type="button" onClick={clearAll} style={{ border: 'none', background: 'none', color: '#4E4CDB', cursor: 'pointer', font: 'inherit', fontSize: '11.5px', fontWeight: 700, padding: 0 }}>전체 해제</button></div>
                <div className="ap-thumbs" data-library-selected-thumbs style={{ marginBottom: 0 }}>
                  {srcs.map((src, i) => (
                    <div className="ap-thumb ap-upload-thumb" key={'push-lib-sel-' + i + '-' + src.slice(-12)} style={{ cursor: 'default' }}>
                      <div className="ap-thumb-media"><img src={src} alt="" draggable={false} /></div>
                      <div className="ap-thumb-actions single" aria-label="불러오기 선택 이미지 액션">
                        <button type="button" className="ap-thumb-action danger" title="이미지 삭제" onClick={(e) => {e.preventDefault();e.stopPropagation();removeSrc(i);}}><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg></button>
                      </div>
                    </div>
                  ))}
                </div>
              </div>}
              <button type="button" onClick={openNukkiPopup} style={{ width: '100%', marginTop: '10px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '7px', border: '1.5px solid #4E4CDB', borderRadius: '10px', background: '#fff', color: '#4E4CDB', font: 'inherit', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><line x1="20" y1="4" x2="8.12" y2="15.88" /><line x1="14.47" y1="14.48" x2="20" y2="20" /><line x1="8.12" y1="8.12" x2="12" y2="12" /></svg>
                누끼 이미지 만들기
              </button>
              {window.AssetLibraryField && <div style={{ marginTop: '8px' }}><AssetLibraryField kind="model" max={rule.modelMax || 3} onApply={applyLibraryModels} /></div>}
            </div>}
          </section>


          <section className="ap-sec">
            <button onClick={() => setAdvOpen((v) => !v)} style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit', padding: 0 }}>
              <span className="ap-sec-head" style={{ margin: 0 }}><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#8a8a96" strokeWidth="2.4" strokeLinecap="round" style={{ transform: advOpen ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }}><path d="m9 6 6 6-6 6" /></svg>고급 설정<span className="ap-opt">{showLeftGradientControls ? '그라데이션 · 배경처리 · 스코어러' : '배경처리 · 스코어러'}</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', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: 'pointer', border: bgMode === o.id ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: bgMode === o.id ? '#eef2ff' : '#fff', color: bgMode === o.id ? '#4E4CDB' : '#6b6b78' }}>{o.label}</button>)}</div>
                <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', margin: '14px 0 6px' }}>배치 우선순위</div>
                <div style={{ display: 'flex', gap: '6px' }}>{[{ id: 'A', t: 'A · 실루엣' }, { id: 'B', t: 'B · 인식' }].map((o) => <button key={o.id} onClick={() => setScorer((s) => ({ ...s, siloMode: o.id }))} style={{ flex: 1, padding: '7px 4px', borderRadius: '8px', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: 'pointer', border: scorer.siloMode === o.id ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: scorer.siloMode === o.id ? '#eef2ff' : '#fff', color: scorer.siloMode === o.id ? '#4E4CDB' : '#6b6b78' }}>{o.t}</button>)}</div>
                <MiniSlider label="목표 밀도" value={scorer.targetDensity} min={0.4} max={1} step={0.01} fmt={(v) => Math.round(v * 100) + '%'} onChange={(v) => setScorer((s) => ({ ...s, targetDensity: v }))} hint="제품 크기 — 세이프를 얼마나 채울지 (겹침과 무관)" />
                <MiniSlider label="겹침 허용치" value={scorer.overlapTol} min={0} max={0.35} step={0.01} fmt={(v) => Math.round(v * 100) + '%'} onChange={(v) => setScorer((s) => ({ ...s, overlapTol: v }))} hint="이만큼까지 제품 겹침 허용" />
                {showLeftGradientControls &&
                <div style={{ marginTop: '14px', paddingTop: '14px', borderTop: '1px solid #eee' }}>
                  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '2px' }}>
                    <span style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e' }}>그라데이션</span>
                    <button onClick={() => { setGradRatio(_gradRatio0); setGradOpacity(1); }} style={{ border: 'none', background: 'none', font: 'inherit', fontSize: '10.5px', color: '#8a8a96', cursor: 'pointer', padding: 0 }}>초기화</button>
                  </div>
                  <MiniSlider label="폭" value={gradRatio} min={0.2} max={gradRatioMax} step={0.01} fmt={(v) => Math.round(v * 100) + '%'} onChange={setGradRatio} hint="배경색이 사진을 덮는 좌측 폭 (이미지존 대비)" />
                  <MiniSlider label="진하기" value={gradOpacity} min={0} max={1} step={0.01} fmt={(v) => Math.round(v * 100) + '%'} onChange={setGradOpacity} hint="0%면 그라데이션 없음 · 100%면 왼쪽 완전 불투명" />
                </div>}
              </div>}
          </section>
        </aside>

        {/* ── 중앙: 미리보기 ── */}
        <main className="ap-main">
          {/* 레이아웃 유형 칩 — 이미지가 있을 때 캔버스 상단에 sticky 오버레이 */}
          {srcs.length > 0 && <div style={{ position: 'sticky', top: 0, zIndex: 10, display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center', padding: '8px 0 10px', background: 'linear-gradient(to bottom, rgba(246,246,250,0.97) 75%, rgba(246,246,250,0))', marginBottom: '4px', pointerEvents: 'auto' }}>
            {BT.filter((t) => t.ready !== false).map((t) => {
              const on = bannerType === t.id;
              return <button key={t.id} type="button" onClick={() => chooseBannerType(t.id)} style={{ padding: '4px 14px', fontFamily: 'inherit', fontSize: '12px', fontWeight: on ? 700 : 500, 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)' }}>{t.label}</button>;
            })}
          </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">{(BT.find((t) => t.id === bannerType) || {}).label} — 준비 중</p><p className="ap-empty-s">이 유형의 배치 엔진은 곧 추가됩니다.</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> :
          busy || catBusy || (is1ETop && typeAuto && !P.disableAutoType && allItems.length > 0 && bannerType !== (allItems.some((it) => it.photoKind === 'photo') ? 'editorial' : 'A')) || 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> :
          !primary ?
          <div className="ap-empty"><p className="ap-empty-t">{bannerType === 'editorial' ? '이 유형으로 배치할 이미지가 없어요' : '누끼형 후보를 만들 수 없어요'}</p><p className="ap-empty-s">{bannerType === 'editorial' ? '화보형 이미지를 올려주세요.' : '제품 누끼 이미지를 올려주세요.'}</p></div> :
          <div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflowY: 'auto', padding: '4px 2px' }}>
                    <div ref={previewRef} className="ap-preview-area" style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 8px' }}>
                      <Banner cand={primary} main={main1e} sub={sub1e} chip={oneEBgColor} textColor={textColor1e} textStyle={{ main: { size: mainSize1e }, sub: { size: subSize1e } }} scale={1.6} gradRatio={gradRatio} gradOpacity={gradOpacity} gradient={effectiveGradient} backgroundExtend={effectiveBackgroundExtend} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} form={renderForm} patchForm={patchForm} engine={ENG} bannerType={bannerType} rects={rectsFor(primary)} onRectsChange={onPrimaryChange} selectedId={selectedId} onSelectRect={setSelectedId} onSelectionChange={setSelectedIds} onBeginEdit={beginEdit} scorer={scorer} onDeleteRect={removeRectSource} />
                    </div>
                    {/* 배치 편집 툴바 — 되돌리기 / 다시 실행 / 배치 초기화 (550과 동일 UX) */}
                    <div style={{ display: 'flex', justifyContent: 'center', padding: '0 0 6px' }}>
                      <div className="ap-card-actions" onPointerDown={(e) => e.stopPropagation()} style={{ width: '520px', maxWidth: '100%', marginTop: 0 }}>
                        <button className="ap-btn icon" title="되돌리기 (⌘Z)" onClick={undo} disabled={!history.length}><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={redo} disabled={!redoList.length}><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={resetPrimary}>배치 초기화</button>
                        {is1ETop && <React.Fragment>
                          <button className="ap-btn ghost" title="선택 이미지 좌우 반전" onClick={flipPrimaryRects}>좌우 반전</button>
                          <button className="ap-btn icon" title="선택 이미지 작게" onClick={() => scalePrimaryRects(0.94)}>−</button>
                          <button className="ap-btn icon" title="선택 이미지 크게" onClick={() => scalePrimaryRects(1.06)}>＋</button>
                          <button className="ap-btn icon" title="왼쪽 정렬" onClick={() => alignPrimaryRects('x', 'start')}>좌</button>
                          <button className="ap-btn icon" title="가운데 정렬" onClick={() => alignPrimaryRects('x', 'center')}>중</button>
                          <button className="ap-btn icon" title="오른쪽 정렬" onClick={() => alignPrimaryRects('x', 'end')}>우</button>
                        </React.Fragment>}
                      </div>
                    </div>
                    {srcs.length > 1 &&
                    <div style={{ width: '576px', maxWidth: '100%', margin: '4px auto 0' }}>
                      <button onClick={() => setMetaOpen1e((v) => !v)} style={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '9px 12px', borderRadius: metaOpen1e ? '8px 8px 0 0' : '8px', border: '1px solid #ececf2', background: '#fff', cursor: 'pointer', font: 'inherit', fontSize: '12px', fontWeight: 700, color: '#43434e' }}><span>적합도 정보</span><span style={{ color: '#8a8a96', fontWeight: 600 }}>{metaOpen1e ? '접기 ▲' : '펼치기 ▼'}</span></button>
                      {metaOpen1e && <Metrics1E cand={primary} />}
                    </div>}
                    {srcs.length > 1 && ranked.length > 1 &&
                    <div className="ap-rankstrip" style={{ marginTop: '16px' }}><div className="ap-rankstrip-head"><b>후보 랭킹</b><span>{ranked.length}개 · 점수순 · 클릭해 선택</span></div>
                      <div className="ap-rankstrip-row" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', padding: '4px 0' }}>
                        {ranked.map((c) => <button key={c.id} onClick={() => setPinnedId(c.id)} style={{ border: c.id === primaryId ? '2px solid #4E4CDB' : '1px solid #e7e7ee', borderRadius: '8px', padding: '2px', background: '#fff', cursor: 'pointer', lineHeight: 0 }}><Thumb cand={c} chip={oneEBgColor} scale={0.85} form={renderForm} engine={ENG} /></button>)}
                      </div>
                    </div>}
                  </div>}
        </main>

        {/* ── 우측: 유형별 인스펙터 — profile.Inspector 있으면 그걸로, 없으면 1E 기본 ── */}
        {P.Inspector
          ? <P.Inspector form={form} patchForm={patchForm} bannerType={bannerType} setBannerType={setBannerType} schema={SCH} colorchips={P.colorchips || []} showToast={showToast} items={allItems} srcs={srcs} imageAdjust={activeImageAdjust} onImageAdjust={applyImageAdjust} outpaintSource={outpaintSource} initialVariants={outpaintVariants} onVariantsChange={setOutpaintVariants} onApplyOutpaint={applyOutpaintVariant} onUseGuideImage={isPush ? addGuidePlaceholderImage : null} guidePlaceholderImage={SCH && SCH.guidePlaceholderImage} />
          : <aside className="ap-controls" style={{ borderLeft: '1px solid #ececf2', borderRight: 'none' }}>
          <div style={{ fontSize: '11px', fontWeight: 800, color: '#9a9aa6', letterSpacing: '0.04em', marginBottom: '10px' }}>텍스트 · 배경 <span style={{ color: '#4E4CDB', fontWeight: 800 }}>1E</span></div>
          {is1ETop && window.BannerImageTools && window.BannerImageTools.OutpaintPanel && <div onPointerDown={(e) => e.stopPropagation()}>{React.createElement(window.BannerImageTools.OutpaintPanel, { source: outpaintSource, initialVariants: outpaintVariants, onVariantsChange: setOutpaintVariants, onApply: applyOutpaintVariant })}</div>}
          {window.BannerImageTools && <div onPointerDown={(e) => e.stopPropagation()}>{React.createElement(window.BannerImageTools.Panel, { value: activeImageAdjust, onChange: applyImageAdjust, disabled: !srcs.length })}</div>}
          <section className="ap-sec">
            <label style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
              <span>메인 텍스트</span>
              <span style={{ fontWeight: 400, fontSize: '11px', color: main1e.length > 15 ? '#e04e4e' : '#9a9aa6' }}>{main1e.length}/15</span>
            </label>
            <input type="text" value={main1e} onChange={(e) => setMain1e(e.target.value)} style={_in1e} />
            <label style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', marginTop: '8px', display: 'block' }}>서브 텍스트</label>
            <input type="text" value={sub1e} onChange={(e) => setSub1e(e.target.value)} style={_in1e} />
            {!is1ETop && <div style={{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #eee' }}>
              <MiniSlider label="메인 글씨 크기" value={mainSize1e} min={14} max={24} step={0.5} fmt={(v) => Number(v).toFixed(1).replace(/\.0$/, '') + 'px'} onChange={setMainSize1e} />
              <MiniSlider label="서브 글씨 크기" value={subSize1e} min={9} max={15} step={0.5} fmt={(v) => Number(v).toFixed(1).replace(/\.0$/, '') + 'px'} onChange={setSubSize1e} />
            </div>}
            <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', margin: '10px 0 6px' }}>텍스트 색</div>
            <div style={{ display: 'flex', gap: '6px' }}>
              {[['#333333', '#333'], ['#ffffff', '#fff']].map(([hex, lb]) =>
              <button key={hex} onClick={() => setTextColor1e(hex)} style={{ flex: 1, padding: '7px 4px', borderRadius: '8px', font: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: 'pointer', border: textColor1e === hex ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: textColor1e === hex ? '#eef2ff' : '#fff', color: textColor1e === hex ? '#4E4CDB' : '#6b6b78' }}>{lb}</button>)}
            </div>
            <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', margin: '12px 0 5px' }}>배경 스타일</div>
            <div style={{ display: 'flex', gap: '18px', alignItems: 'center', marginBottom: '12px' }}>
              {[['gradient', '그라데이션'], ['solid', '단색형']].map(([v, lbl]) => (
                <label key={v} style={{ display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer', fontSize: '12.5px', fontWeight: bgStyle === v ? 700 : 500, color: bgStyle === v ? '#4E4CDB' : '#43434e' }}>
                  <input type="radio" name="bgStyle1e" value={v} checked={bgStyle === v} onChange={() => { setBgStyle(v); setBgStyleManual(true); }} style={{ accentColor: '#4E4CDB', cursor: 'pointer', width: '13px', height: '13px', margin: 0 }} />
                  {lbl}
                </label>
              ))}
            </div>
            {effectiveGradient &&
            <div style={{ marginBottom: '12px', padding: '10px 10px 12px', border: '1px solid #ececf2', borderRadius: '8px', background: '#fafafb' }}>
              {primaryNeedsOneEBackdrop && <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px', marginBottom: '10px' }}>
                <span style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e' }}>배경 확장</span>
                <button type="button" onClick={() => setBgExtend1e((v) => !v)} aria-pressed={bgExtend1e ? 'true' : 'false'} style={{ minWidth: '58px', padding: '5px 10px', borderRadius: '999px', border: bgExtend1e ? '1.5px solid #4E4CDB' : '1px solid #d8d8e0', background: bgExtend1e ? '#eef2ff' : '#fff', color: bgExtend1e ? '#4E4CDB' : '#6b6b78', font: 'inherit', fontSize: '11px', fontWeight: 800, cursor: 'pointer' }}>{bgExtend1e ? '켬' : '끔'}</button>
              </div>}
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '-4px' }}>
                <span style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e' }}>그라데이션 조절</span>
                <button onClick={() => { setGradRatio(_gradRatio0); setGradOpacity(1); }} style={{ border: 'none', background: 'none', font: 'inherit', fontSize: '10.5px', color: '#8a8a96', cursor: 'pointer', padding: 0 }}>초기화</button>
              </div>
              <MiniSlider label="폭" value={gradRatio} min={0.2} max={gradRatioMax} step={0.01} fmt={(v) => Math.round(v * 100) + '%'} onChange={setGradRatio} hint="이미지존을 덮는 좌측 그라데이션 폭" />
              <MiniSlider label="진하기" value={gradOpacity} min={0} max={1} step={0.01} fmt={(v) => Math.round(v * 100) + '%'} onChange={setGradOpacity} hint="좌측 배경색 덮임 정도" />
            </div>}
            <div style={{ fontSize: '11.5px', fontWeight: 700, color: '#43434e', margin: '0 0 6px', display: 'flex', alignItems: 'center', gap: '6px' }}>배경 컬러칩 <span style={{ color: '#9a9aa6', fontWeight: 500 }}>(수동 보정)</span><span style={{ fontSize: '10px', fontWeight: 700, padding: '1px 6px', borderRadius: '5px', background: chipAuto ? '#eef2ff' : '#f0f0f3', color: chipAuto ? '#4E4CDB' : '#8a8a96' }}>{oneEChipStatus}</span>{!chipAuto && <button onClick={() => setChipAuto(true)} 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: '6px', flexWrap: 'wrap' }}>
              {(window.COLORCHIP_1E_TOP || []).map((c) =>
              <button key={c.id} onClick={() => { setChip1e(c.hex); setChipAuto(false); }} title={c.label} style={{ width: '26px', height: '26px', borderRadius: '50%', background: c.hex, cursor: 'pointer', padding: 0, border: oneEChipApplied && chip1e === c.hex ? '2px solid #1c1c22' : '2px solid transparent', boxShadow: oneEChipApplied && chip1e === c.hex ? '0 0 0 2px #fff inset' : 'none' }} />)}
              <label title="직접 색 선택" style={{ position: 'relative', width: '26px', height: '26px', borderRadius: '50%', cursor: 'pointer', boxSizing: 'border-box', border: oneEChipApplied && !(window.COLORCHIP_1E_TOP || []).some((c) => c.hex === chip1e) ? '2px solid #1c1c22' : '2px solid transparent', background: 'conic-gradient(#ff2d2d,#ffd23f,#4ade80,#38bdf8,#6366f1,#e252e2,#ff2d2d)', boxShadow: oneEChipApplied && !(window.COLORCHIP_1E_TOP || []).some((c) => c.hex === chip1e) ? '0 0 0 2px #fff inset' : 'none', display: 'inline-block' }}><input type="color" value={normHex(chip1e) || '#aeaeae'} onChange={(e) => { setChip1e(e.target.value); setChipAuto(false); }} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', border: 'none', padding: 0, margin: 0 }} /></label>
            </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: TITLE, type: ((BT.find((t) => t.id === bannerType) || {}).label || bannerType || ''), fileNameRule: saveFileNameRule, 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); }, getBlob: _renderBlob1e, onClose: () => setSaveOpen(false), onConfirm: (v) => save1e({ ...v, type: ((BT.find((t) => t.id === bannerType) || {}).label || bannerType || '') }) })}
      {toast && <div className="ap-toast" role="status" aria-live="polite">{toast}</div>}
    </div>);

}

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, src, onPick, onClear, onDropFiles, kind, pos, onPosChange }) {
  const t = ADD_IMG_TYPES.find((x) => x.id === type) || ADD_IMG_TYPES[0];
  const p = pos || { x: 50, y: 50 };
  const panRef = React.useRef(null);
  const startPan = (e) => {
    if (!src || kind !== 'photo' || !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({ 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);
  };
  return (
    <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 }}>{t.label}</span>
      </div>
      <div onClick={src ? undefined : onPick} 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' ? 'grab' : 'default' : 'pointer', touchAction: 'none' }}>
        {src ? <img src={src} alt="" draggable={false} style={kind === 'nukki' ? { width: '100%', height: '100%', objectFit: 'contain', padding: '12%', boxSizing: 'border-box', pointerEvents: 'none' } : { width: '100%', height: '100%', objectFit: 'cover', objectPosition: p.x + '% ' + p.y + '%', pointerEvents: 'none' }} /> : <span style={{ color: '#b5b5c0', fontSize: '24px' }}>+</span>}
        {src && kind === 'photo' && <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>);

}
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];
    return (
      <div style={{ width: '130px', height: '130px', borderRadius: '15px', background: '#fff', overflow: 'hidden', position: 'relative', boxShadow: '13px 15px 50px 0px rgba(202,202,202,0.5)' }}>
        <div style={{ position: 'absolute', left: 0, top: 0, width: '130px', height: '40px', borderRadius: '15px 15px 0 0', background: '#680ABA', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
          {t.icon && <span style={{ position: 'relative', width: '22px', height: '22px', borderRadius: '50%', background: '#38006A', flex: '0 0 auto', display: 'inline-block' }}><span style={{ position: 'absolute', left: '10px', top: '5px', width: '2px', height: '12px', background: '#fff', borderRadius: '1px' }} /><span style={{ position: 'absolute', left: '5px', top: '10px', width: '12px', height: '2px', background: '#fff', borderRadius: '1px' }} /></span>}
          <span style={{ color: '#fff', fontSize: '20px', fontWeight: 600, letterSpacing: '-0.02em' }}>{t.label}</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' } : { width: '100%', height: '100%', objectFit: 'cover', objectPosition: (deco.pos ? deco.pos.x : 50) + '% ' + (deco.pos ? deco.pos.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' }}>인증<br />플래그</span></div>)}</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;
}
if (!window.DecoBadge) window.DecoBadge = DecoBadge;
function FaceMarkEditor({ src, initial, onSave, onClose }) {
  const W = 340;
  const wrapRef = React.useRef(null);
  const [img, setImg] = React.useState(null);
  const [head, setHead] = React.useState(initial && initial.head != null ? initial.head : 0.08);
  const [chin, setChin] = React.useState(initial && initial.chin != null ? initial.chin : 0.22);
  React.useEffect(() => {const im = new Image();im.onload = () => setImg(im);im.src = src;}, [src]);
  const H = img ? Math.round(W * img.height / Math.max(1, img.width)) : 300;
  const drag = (which) => (e) => {
    e.preventDefault();
    const rect = wrapRef.current.getBoundingClientRect();
    const mv = (ev) => {const f = Math.max(0, Math.min(1, (ev.clientY - rect.top) / rect.height));which === 'head' ? setHead(f) : setChin(f);};
    const up = () => {window.removeEventListener('pointermove', mv);window.removeEventListener('pointerup', up);};
    window.addEventListener('pointermove', mv);window.addEventListener('pointerup', up);
  };
  const lines = [['head', head, '#E0322E', '머리끝'], ['chin', chin, '#2A6FDB', '턱끝']];
  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(20,20,28,.55)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onPointerDown={(e) => {if (e.target === e.currentTarget) onClose();}}>
      <div style={{ background: '#fff', borderRadius: '14px', padding: '18px', width: W + 36 + 'px', boxShadow: '0 20px 60px rgba(0,0,0,.3)' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}><b style={{ fontSize: '14px', color: '#1c1c22' }}>기준선 지정</b><button onClick={onClose} style={{ border: 'none', background: 'none', cursor: 'pointer', color: '#8a8a96', fontSize: '20px', lineHeight: 1 }}>×</button></div>
        <p style={{ fontSize: '11.5px', color: '#8a8a96', margin: '0 0 12px' }}>두 라인을 드래그해 머리끝·턱끝에 맞추세요. 이 값으로 모델끼리 정렬됩니다.</p>
        <div ref={wrapRef} style={{ position: 'relative', width: W + 'px', height: H + 'px', margin: '0 auto', background: '#f4f4f8', borderRadius: '8px', overflow: 'hidden', userSelect: 'none', touchAction: 'none' }}>
          {img && <img src={src} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />}
          {lines.map(([k, v, c, lbl]) => <div key={k} onPointerDown={drag(k)} style={{ position: 'absolute', left: 0, right: 0, top: v * H + 'px', height: '16px', marginTop: '-8px', cursor: 'ns-resize' }}><div style={{ position: 'absolute', left: 0, right: 0, top: '7px', height: '2px', background: c }} /><span style={{ position: 'absolute', left: '4px', top: '-1px', fontSize: '10px', fontWeight: 800, color: '#fff', background: c, padding: '1px 6px', borderRadius: '4px' }}>{lbl}</span></div>)}
        </div>
        <div style={{ display: 'flex', gap: '8px', marginTop: '14px' }}>
          <button onClick={onClose} style={{ flex: 1, padding: '10px', borderRadius: '9px', border: '1px solid #e7e7ee', background: '#fff', cursor: 'pointer', font: 'inherit', fontWeight: 700, color: '#6b6b78' }}>취소</button>
          <button onClick={() => onSave(Math.min(head, chin), Math.max(head, chin))} style={{ flex: 1, padding: '10px', borderRadius: '9px', border: 'none', background: '#4E4CDB', color: '#fff', cursor: 'pointer', font: 'inherit', fontWeight: 700 }}>저장</button>
        </div>
      </div>
    </div>);

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

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

}

/* [공유 팩토리] 밖으로 노출:
   · window.__APEditor  : 팩토리 컴포넌트 — 다른 유형(앱푸시 등)이 profile 을 넘겨 같은 화면 재사용
   · window.AutoPlace1E : 1E = 팩토리를 profile 없이 호출 → 전부 1E 기본값 → 기존 동작 그대로(무회귀) */

  /* ── 앱푸시 전용 컴포넌트/설정 ── */
const __APColorLocal = { dominantVivid: dominantVivid1E, photoBg: photoBgColor1E, photoStats: photoConnectorStats1E, hexToRgb: _hexToRgb1E, hsl: _hsl1E };
try { window.__1ETopColor = { dominantVivid: dominantVivid1E, photoBg: photoBgColor1E, photoStats: photoConnectorStats1E, bestPhotoChip: bestPhotoColorchip1E, nearestChip: nearestColorchip1E }; } catch (e) {}
  const { useState: useP, useRef: usePR, useEffect: usePE } = React;
  const readAsDataURL = (file) => new Promise((res) => { const r = new FileReader(); r.onload = () => res(r.result); r.readAsDataURL(file); });
  function normalizePushTextColor(value) {
    const v = String(value || '').trim().toLowerCase();
    if (v === '#fff' || v === '#ffffff' || v === 'white') return '#ffffff';
    if (v === '#333' || v === '#333333') return '#333333';
    return String(value || '').trim() || '#333333';
  }
  const THEME = (tc) => normalizePushTextColor(tc).toLowerCase() === '#ffffff' ? 'light' : 'dark';
  const PUSH_GUIDE_FALLBACK_BG = { id: 'gray', label: '그레이', css: 'linear-gradient(180deg, #DEDEDE 0%, #C7C7C7 100%)', from: '#DEDEDE', to: '#C7C7C7', solid: '#D3D3D3' };
  function normalizePushBgOption(opt) {
    if (!opt) return null;
    if (typeof opt === 'string') return { id: opt, label: opt, css: opt, from: firstHex(opt), solid: firstHex(opt) };
    return {
      id: opt.id || opt.css || opt.value || opt.solid || opt.from,
      label: opt.label || opt.id || opt.css || opt.value || opt.solid || '',
      css: opt.css || opt.value || opt.solid || opt.from,
      from: opt.from || firstHex(opt.css || opt.value || opt.solid),
      to: opt.to || null,
      solid: opt.solid || opt.from || firstHex(opt.css || opt.value || opt.value),
    };
  }
  function pushBgOptions() {
    const SCH = window.SCHEMA_PUSH || {};
    const src = (SCH.backgroundGradients && SCH.backgroundGradients.length) ? SCH.backgroundGradients : (SCH.colorchips || []);
    const list = src.map(normalizePushBgOption).filter((x) => x && x.css);
    return list.length ? list : [PUSH_GUIDE_FALLBACK_BG];
  }
  function firstHex(v) {
    const m = String(v || '').match(/#[0-9a-f]{3,8}/i);
    return m ? m[0] : null;
  }
  function samePushBg(a, b) {
    return String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();
  }
  function pushBgLabel(v) {
    const hit = pushBgOptions().find((o) => samePushBg(o.css, v));
    if (hit) return hit.label;
    const hex = firstHex(v);
    return hex ? '스포이드 ' + hex.toUpperCase() : String(v || '').toUpperCase();
  }
  function pushLogoOptions() {
    const logo = window.SCHEMA_PUSH && window.SCHEMA_PUSH.fixed && window.SCHEMA_PUSH.fixed.logo;
    const opts = logo && Array.isArray(logo.options) ? logo.options : null;
    return opts && opts.length ? opts : [
      { id: 'purple', label: '퍼플', color: '#680ABA' },
      { id: 'white', label: '화이트', color: '#ffffff' },
    ];
  }
  function normalizePushLogoMode(value) {
    const v = String(value || '').trim();
    if (v === 'white' || v === 'purple') return v;
    return 'purple';
  }
  function pushLogoColor(mode) {
    const opt = pushLogoOptions().find((o) => o.id === mode);
    return opt && opt.color ? opt.color : (mode === 'white' ? '#ffffff' : '#680ABA');
  }
  function nearestPushBgOption(hex) {
    const opts = pushBgOptions();
    const C = __APColorLocal;
    if (!C || !hex) return opts[opts.length - 1] || opts[0];
    const hs = C.hsl(C.hexToRgb(hex));
    let best = opts[0], bd = Infinity;
    opts.forEach((o) => {
      const ref = o.solid || o.from || firstHex(o.css);
      const cc = C.hsl(C.hexToRgb(ref));
      let dh = Math.abs(hs[0] - cc[0]);
      if (dh > 180) dh = 360 - dh;
      if (dh < bd) { bd = dh; best = o; }
    });
    return best;
  }
  function mixHex(a, b, t) {
    const C = __APColorLocal;
    const ar = C.hexToRgb(normHex(a) || '#d3d3d3');
    const br = C.hexToRgb(normHex(b) || '#ffffff');
    const v = [ar[0] + (br[0] - ar[0]) * t, ar[1] + (br[1] - ar[1]) * t, ar[2] + (br[2] - ar[2]) * t];
    return '#' + v.map((x) => Math.round(Math.max(0, Math.min(255, x))).toString(16).padStart(2, '0')).join('');
  }
  function pushGradientFromHex(hex) {
    const base = normHex(hex);
    if (!base) return null;
    const top = mixHex(base, '#ffffff', 0.18);
    const bottom = mixHex(base, '#000000', 0.08);
    return { css: `linear-gradient(180deg, ${top} 0%, ${bottom} 100%)`, from: top, solid: base };
  }
  function hasPhotoRect(cand) {
    if (!cand) return false;
    const rd = cand._render || {};
    if (rd.type === 'editorial' || rd.gradient) return true;
    return ((cand.rects || []).some((r) => r.photoKind === 'photo' || r.fit === 'cover' || r.cover));
  }
  function hasModelRect(cand) {
    if (!cand) return false;
    const m = cand.metrics || {};
    if (m.fashion || m.modelRow || m.mixedNukki || m.models) return true;
    return ((cand.rects || []).some((r) => r && r.role === 'model'));
  }
  function pushGuidePatternFor(form) {
    const SCH = window.SCHEMA_PUSH || {};
    const f = form || {};
    const typeId = f.type || 'basic';
    const patterns = SCH.guidePatterns || {};
    const pattern = patterns[typeId] || patterns.basic || {};
    const hasSticker = typeId === 'leading' && !!String(f.sticker || '').trim();
    const zone = hasSticker && pattern.stickerImageZone ? pattern.stickerImageZone : pattern.imageZone;
    const out = { guidePattern: pattern.id || typeId };
    if (zone) {
      if (zone.x != null) out.imgX = zone.x;
      if (zone.y != null) out.imgTop = zone.y;
      if (zone.w != null) out.imgW = zone.w;
      if (zone.rightBleed != null) out.rightBleed = zone.rightBleed;
      if (zone.focal && zone.focal.x != null) out.focalX = zone.focal.x;
      if (zone.focal && zone.focal.y != null) out.focalY = zone.focal.y;
    }
    return out;
  }

  // spec 조립 (미리보기·저장 공용)
  // 기존 저장값에 연도가 남아 있어도 앱푸시 표시 규칙은 월/일만 허용한다.
  function stripPushAirtimeYear(value) {
    return String(value || '')
      .replace(/\b(?:19|20)\d{2}\s*년\s*/g, '')
      .replace(/\b(?:19|20)\d{2}\s*[-./]\s*/g, '')
      .replace(/\s{2,}/g, ' ')
      .trim();
  }

  // 방송 일시(YYYY-MM-DD) + 시간(HH:MM) → "MM/DD (요일) HH:MM" (날짜는 제로패딩 MM/DD, 요일은 날짜에서 자동)
  function composeAirtime(dateStr, timeStr) {
    if (!dateStr) return '';
    const d = new Date(dateStr + 'T00:00:00');
    if (isNaN(d.getTime())) return '';
    const wd = ['일', '월', '화', '수', '목', '금', '토'][d.getDay()];
    const mm = String(d.getMonth() + 1).padStart(2, '0');
    const dd = String(d.getDate()).padStart(2, '0');
    const base = mm + '/' + dd + ' (' + wd + ')';
    return stripPushAirtimeYear(timeStr ? base + ' ' + timeStr : base);
  }
  window.__normalizePushCopy = normalizePushCopy;
  window.__normalizePushCopyInput = normalizePushCopyInput;
  window.__composePushAirtime = composeAirtime;
  window.__stripPushAirtimeYear = stripPushAirtimeYear;

  const specFrom = (cand, f, preview, scale) => {
    const it = f.imageType || 'editorial';
    const shouldGradient = f.gradient != null ? !!f.gradient : (it !== 'nukki' || hasPhotoRect(cand));
    const SCH = window.SCHEMA_PUSH || {};
    const typeDef = (SCH.types || []).find((x) => x.id === (f.type || 'basic')) || null;   // 선택 유형(basic/airtime…)
    const textColor = normalizePushTextColor(f.textColor);
    const copyLimit = pushCopyLimits(SCH, f);
    const airtime = (typeDef && typeDef.airtimeRow)   // 방송시간 타입이면 항상 전달(미입력이면 렌더가 preview에서 예시를 흐리게)
      ? { badge: f.badge || '', time: composeAirtime(f.airtimeDate, f.airtimeTime) || stripPushAirtimeYear(f.airtime), style: typeDef.airtimeRow, example: typeDef.airtimeRow.example || null }
      : null;
    const programLogoKind = f.programLogoKind === 'text' ? 'text' : 'image';
    const programLogo = (typeDef && typeDef.programLogo) ? Object.assign({}, typeDef.programLogo, {
      src: programLogoKind === 'image' ? (f.programLogo || null) : null,
      text: programLogoKind === 'text' ? (f.programLogoText || '') : '',
      color: textColor,
      scalePct: f.programLogoScale || 100,
      offsetX: f.programLogoX || 0,
      offsetY: f.programLogoY || 0,
    }) : null;   // 로고형·리딩
    const timeSticker = (typeDef && typeDef.timeSticker) ? Object.assign({}, typeDef.timeSticker, { text: f.sticker || '' }) : null;         // 리딩(선택)
    // 로고강조: "오늘혜택좋은+브랜드" 한 이미지를 콜라보로고 슬롯(우상단)에 렌더
    const colab = (typeDef && typeDef.brandAsColab) ? (f.brandLogo || f.colabLogo || null) : (f.colabLogo || null);
    return {
      ...pushGuidePatternFor(f),
      imageType: it,
      bgColor: f.bgColor || pushBgOptions()[0].css,
      gradientColor: f.gradColor || firstHex(f.bgColor) || pushBgOptions()[0].from || PUSH_GUIDE_FALLBACK_BG.from,   // 이미지색 자동
      rects: (cand && cand.rects) || [],
      gradient: shouldGradient,            // 화보/사진 rect 포함 또는 고급설정 선택 = 좌측 스크림(문구 가독)
      gradientRatio: f.gradientRatio != null ? f.gradientRatio : 1.08,
      gradientOpacity: f.gradientOpacity != null ? f.gradientOpacity : DEFAULT_GRAD_OPACITY,
      imageAdjust: f.imageAdjust,
      logoMode: normalizePushLogoMode(f.logoMode),
      logoColor: pushLogoColor(normalizePushLogoMode(f.logoMode)),
      colabLogo: colab,
      text: { copy: normalizePushCopy(f.copy || '', copyLimit.lines, copyLimit.chars) },
      textColor: textColor,
      theme: THEME(textColor),
      align: 'top',   // 스펙 고정: 문구는 상단(top) — 세로정렬 선택 옵션 제거(설계 스펙엔 없음)
      copyBox: (typeDef && typeDef.copyBox) || null,   // 유형별 문구박스 덮어쓰기(방송시간 등)
      airtime: airtime,                                 // 방송시간 행 [배지+시간]
      programLogo: programLogo,                         // 로고형·리딩 프로그램 로고
      timeSticker: timeSticker,                         // 리딩 시간 스티커
      preview: !!preview,
      scale: scale || 2,
    };
  };

  /* ── 편집 스테이지: 이미지존 안에서 누끼 오브젝트 드래그·크기조절 (앱푸시 전용) ──
     · 중립 부품 아님(유형별 편집). 손동작만 담당, 재정렬(reflow) 없이 자유 이동/크기.
     · 저장은 편집된 rects 로 캔버스 합성. */
  function PushEditStage({ rects, dispW, cw, ch, imgZone, bounds, scorer, selectedId, onSelect, onChange, onBegin, noReflow = true, onDeleteItem, hideImages = false, hideChrome = false, imageAdjust, imageAdjustBySource, onSelectionChange, foreground }) {
    const scale = dispW / cw;
    const drag = usePR(null);
    const wrapRef = usePR(null);
    const deleteGuardRef = usePR({ key: '', at: 0 });
    const multiIdsRef = usePR([]);
    const [multiIdsState, setMultiIdsState] = useP([]);
    const [resizeModeId, setResizeModeId] = useP(null);
    const [ctxMenu, setCtxMenu] = useP(null);  // {x, y, idx}
    const zx = (imgZone && imgZone.imgX) || 0, zw = (imgZone && imgZone.imgW) || cw;
    const CORNERS = ['nw', 'ne', 'sw', 'se'];
    const setMultiIds = (next) => {
      const v = typeof next === 'function' ? next(multiIdsRef.current) : next;
      multiIdsRef.current = v || [];
      setMultiIdsState(v || []);
      onSelectionChange && onSelectionChange((v || []).slice());
    };
    const groupIds = multiIdsState.length >= 2 ? multiIdsState : [];
    const selectionIds = groupIds.length ? groupIds : (selectedId != null ? [selectedId] : []);
    const selectedSet = new Set(selectionIds);
    const rectById = () => new Map((rects || []).map((r, i) => [r.id || ('__idx' + i), { r, i }]));
    const visibleBoxOf = (ids) => {
      const by = rectById();
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      (ids || []).forEach((id) => {
        const hit = by.get(id);
        if (!hit) return;
        const r = hit.r;
        const vx = Math.max(r.x, zx), vy = Math.max(r.y, 0);
        const vr = Math.min(r.x + r.w, zx + zw), vb = Math.min(r.y + r.h, ch);
        if (vr <= vx || vb <= vy) return;
        minX = Math.min(minX, vx); minY = Math.min(minY, vy);
        maxX = Math.max(maxX, vr); maxY = Math.max(maxY, vb);
      });
      return Number.isFinite(minX) ? { x: minX, y: minY, w: maxX - minX, h: maxY - minY } : null;
    };
    const actualBoxOf = (list) => {
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      (list || []).forEach((r) => {
        minX = Math.min(minX, r.x); minY = Math.min(minY, r.y);
        maxX = Math.max(maxX, r.x + r.w); maxY = Math.max(maxY, r.y + r.h);
      });
      return Number.isFinite(minX) ? { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) } : null;
    };
    usePE(() => {
      if (hideChrome) return;
      if (selectedId == null) {setMultiIds([]);setResizeModeId(null);}
      else if (resizeModeId != null && resizeModeId !== selectedId) setResizeModeId(null);
    }, [selectedId, hideChrome]);
    // 레이어 순서 변경 (앞으로/뒤로)
    const changeZ = (idx, dir) => {
      const next = rects.map((r) => ({ ...r }));
      const zs = next.map((r) => r.z || 0);
      next[idx].z = dir === 'front' ? Math.max.apply(null, zs) + 1 : Math.min.apply(null, zs) - 1;
      onBegin && onBegin();
      onChange && onChange(next);
      setCtxMenu(null);
    };
    const stopDeleteEvent = (e) => {
      e.preventDefault(); e.stopPropagation();
      if (e.nativeEvent && e.nativeEvent.stopImmediatePropagation) e.nativeEvent.stopImmediatePropagation();
    };
    const deleteItem = (e, idx) => {
      stopDeleteEvent(e);
      const r = rects[idx];
      if (!r || !onDeleteItem) return;
      const key = (r.id || '') + '|' + (r.src || r.url || '');
      const now = Date.now();
      if (deleteGuardRef.current.key === key && now - deleteGuardRef.current.at < 300) return;
      deleteGuardRef.current = { key, at: now };
      onDeleteItem(r);
      setCtxMenu(null);
    };
    const resizeItem = (e, idx) => {
      stopDeleteEvent(e);
      const r = rects[idx];
      if (!r) return;
      setMultiIds([]);
      onSelect && onSelect(r.id);
      setResizeModeId(r.id);
      setCtxMenu(null);
    };
    // 컨텍스트 메뉴 닫기
    usePE(() => {
      if (hideChrome || !ctxMenu) return;
      const close = () => setCtxMenu(null);
      window.addEventListener('pointerdown', close);
      return () => window.removeEventListener('pointerdown', close);
    }, [ctxMenu, hideChrome]);
    // 방향키 이동 (선택 시, Shift=10px)
    usePE(() => {
      if (hideChrome || (selectedId == null && multiIdsRef.current.length < 2)) return;
      const onKey = (e) => {
        if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].indexOf(e.key) < 0) return;
        e.preventDefault();
        onBegin && onBegin();
        const step = e.shiftKey ? 10 : 1;
        const next = rects.map((r) => ({ ...r }));
        const ids = multiIdsRef.current.length >= 2 ? multiIdsRef.current : [selectedId];
        const targets = new Set(ids);
        next.forEach((t) => {
          if (!targets.has(t.id)) return;
          if (e.key === 'ArrowUp') t.y -= step;
          else if (e.key === 'ArrowDown') t.y += step;
          else if (e.key === 'ArrowLeft') t.x -= step;
          else if (e.key === 'ArrowRight') t.x += step;
        });
        onChange && onChange(next);
      };
      window.addEventListener('keydown', onKey);
      return () => window.removeEventListener('keydown', onKey);
    }, [selectedId, multiIdsState, rects, onChange, onBegin, hideChrome]);
    const onMove = (e) => {
      const dr = drag.current; if (!dr) return;
      const dx = (e.clientX - dr.sx) / scale, dy = (e.clientY - dr.sy) / scale;
      const next = rects.map((r) => ({ ...r }));
      if (dr.ids && dr.ids.length >= 2) {
        const ids = new Set(dr.ids);
        if (dr.mode === 'group-move') {
          const gb = dr.groupBox;
          const minX = Math.min(zx, zx + zw - gb.w), maxX = Math.max(zx, zx + zw - gb.w);
          const minY = Math.min(0, ch - gb.h), maxY = Math.max(0, ch - gb.h);
          const ndx = Math.max(minX, Math.min(maxX, gb.x + dx)) - gb.x;
          const ndy = Math.max(minY, Math.min(maxY, gb.y + dy)) - gb.y;
          next.forEach((r) => {
            const o = dr.origMap[r.id];
            if (!ids.has(r.id) || !o) return;
            r.x = o.x + ndx; r.y = o.y + ndy;
          });
        } else if (dr.mode && dr.mode.indexOf('group-') === 0) {
          const c = dr.mode.replace('group-', '');
          const gb = dr.groupBox;
          const east = c[1] === 'e', south = c[0] === 's';
          const anchorX = east ? gb.x : gb.x + gb.w;
          const anchorY = south ? gb.y : gb.y + gb.h;
          const wantW = Math.max(48, gb.w + (east ? dx : -dx));
          const k = Math.max(0.15, wantW / Math.max(1, gb.w));
          next.forEach((r) => {
            const o = dr.origMap[r.id];
            if (!ids.has(r.id) || !o) return;
            r.x = anchorX + (o.x - anchorX) * k;
            r.y = anchorY + (o.y - anchorY) * k;
            r.w = Math.max(18, o.w * k);
            r.h = Math.max(18, o.h * k);
          });
        }
        drag.current.last = next;
        onChange && onChange(next);
        return;
      }
      const t = next[dr.idx], o = dr.orig;
      if (dr.mode === 'move') {
        if (dr.role === 'model') {
          // 인물 누끼: 자유 이동 — 프레임(캔버스) 밖으로 일부 밀어낼 수 있음. 완전 이탈만 막게 최소 40px는 화면 안에 남김.
          const m = 40;
          t.x = Math.max(-o.w + m, Math.min(cw - m, o.x + dx));
          t.y = Math.max(-o.h + m, Math.min(ch - m, o.y + dy));
        } else {
          // 커버 유지 클램프(상품/화보) — 이미지가 존보다 크면 패닝(범위 음수), 작으면 존 안에 담김
          const minX = Math.min(zx, zx + zw - o.w), maxX = Math.max(zx, zx + zw - o.w);
          const minY = Math.min(0, ch - o.h),       maxY = Math.max(0, ch - o.h);
          t.x = Math.max(minX, Math.min(maxX, o.x + dx));
          t.y = Math.max(minY, Math.min(maxY, o.y + dy));
        }
      } else {
        const a = o.aspect || o.w / Math.max(1, o.h);
        const w = Math.max(24, o.w + (dr.mode === 'se' || dr.mode === 'ne' ? dx : -dx));
        const h = w / a;
        t.w = w; t.h = h;
        t.x = (dr.mode === 'nw' || dr.mode === 'sw') ? o.x + (o.w - w) : o.x;
        t.y = (dr.mode === 'nw' || dr.mode === 'ne') ? o.y + (o.h - h) : o.y;
      }
      drag.current.last = next;
      onChange && onChange(next);
    };
    const onUp = () => {
      const dr = drag.current;
      if (dr && dr.last) {
        const draggedIsModel = rects[dr.idx] && rects[dr.idx].role === 'model';
        if (noReflow || draggedIsModel) {
          onChange && onChange(dr.last);
        } else if (bounds && window.AP550) {
          const movedId = rects[dr.idx] ? rects[dr.idx].id : null;
          const sc = scorer || (window.AP550.DEFAULT_SCORER || {});
          const models = dr.last.filter((r) => r.role === 'model');
          const others = dr.last.filter((r) => r.role !== 'model');
          const reflowed = dr.mode === 'move'
            ? (window.AP550.reflowMove ? window.AP550.reflowMove(others, bounds, sc, movedId) : others)
            : (window.AP550.reflow ? window.AP550.reflow(others, bounds, sc, null) : others);
          onChange && onChange((reflowed || others).concat(models));
        }
      }
      drag.current = null; window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp);
    };
    const onItemDown = (e, idx, mode) => {
      e.stopPropagation(); e.preventDefault();
      const r = rects[idx];
      if (!r) return;
      const toggle = mode === 'move' && (e.shiftKey || e.ctrlKey || e.metaKey);
      if (toggle) {
        const base = multiIdsRef.current.length ? multiIdsRef.current : (selectedId != null ? [selectedId] : []);
        const exists = base.indexOf(r.id) >= 0;
        const next = exists ? base.filter((id) => id !== r.id) : base.concat(r.id);
        setMultiIds(next.length >= 2 ? next : []);
        onSelect && onSelect(r.id);
        return;
      }
      const curGroup = multiIdsRef.current.length >= 2 ? multiIdsRef.current : [];
      const groupDrag = mode === 'move' && curGroup.indexOf(r.id) >= 0;
      if (!groupDrag && mode === 'move') setMultiIds([]);
      onSelect && onSelect(r.id);
      onBegin && onBegin();
      if (groupDrag) {
        const ids = curGroup.slice();
        const selectedRects = rects.filter((x) => ids.indexOf(x.id) >= 0);
        const origMap = {};
        selectedRects.forEach((x) => { origMap[x.id] = { ...x }; });
        drag.current = { idx, ids, mode: 'group-move', sx: e.clientX, sy: e.clientY, groupBox: actualBoxOf(selectedRects), origMap };
      } else {
        drag.current = { idx, mode, sx: e.clientX, sy: e.clientY, orig: { ...r }, role: r.role };
      }
      window.addEventListener('pointermove', onMove);
      window.addEventListener('pointerup', onUp);
    };
    const onGroupHandleDown = (e, corner) => {
      e.stopPropagation(); e.preventDefault();
      const ids = multiIdsRef.current;
      if (ids.length < 2) return;
      const selectedRects = rects.filter((x) => ids.indexOf(x.id) >= 0);
      const gb = actualBoxOf(selectedRects);
      if (!gb) return;
      const origMap = {};
      selectedRects.forEach((x) => { origMap[x.id] = { ...x }; });
      onBegin && onBegin();
      drag.current = { ids: ids.slice(), mode: 'group-' + corner, sx: e.clientX, sy: e.clientY, groupBox: gb, origMap };
      window.addEventListener('pointermove', onMove);
      window.addEventListener('pointerup', onUp);
    };
    const openCtxMenu = (e, idx) => {
      e.preventDefault(); e.stopPropagation();
      onSelect && onSelect(rects[idx].id);
      const wr = wrapRef.current ? wrapRef.current.getBoundingClientRect() : { left: 0, top: 0 };
      setCtxMenu({ x: e.clientX - wr.left, y: e.clientY - wr.top, idx });
    };
    const ordered = rects.map((r, i) => ({ r, i })).sort((a, b) => (a.r.z || 0) - (b.r.z || 0));
    const zR = zx + zw;   // 이미지존 우측 경계
    const groupBox = groupIds.length >= 2 ? visibleBoxOf(groupIds) : null;
    return (
      <div ref={wrapRef} style={{ position: 'relative', width: dispW + 'px', height: (ch * scale) + 'px' }}
        onPointerDown={hideChrome ? undefined : () => { setMultiIds([]); onSelect && onSelect(null); setCtxMenu(null); }}>
        {/* 이미지 레이어 — 이미지존으로만 클립(핸들은 클립하지 않음) */}
        {!hideImages && <div data-onee-layer={foreground ? 'image' : undefined} style={{ position: 'absolute', inset: 0, clipPath: 'inset(0 0 0 ' + (zx * scale) + 'px)', pointerEvents: 'none', zIndex: foreground ? 0 : undefined }}>
          {ordered.map(({ r, i }) => (
            <img key={(r.id || 'item') + ':' + i} src={r.url} alt="" draggable={false}
              style={{ position: 'absolute', left: r.x * scale, top: r.y * scale, width: r.w * scale, height: r.h * scale, objectFit: (r.fit === 'cover' || r.cover) ? 'cover' : 'contain', objectPosition: r.focal ? (r.focal.x * 100) + '% ' + (r.focal.y * 100) + '%' : 'center', transform: r.flipX ? 'scaleX(-1)' : undefined, zIndex: (r.z || 0) + 1, ...(window.BannerImageTools ? window.BannerImageTools.imageStyle(window.BannerImageTools.forSource ? window.BannerImageTools.forSource(imageAdjust, imageAdjustBySource, r.src || r.url) : imageAdjust) : {}) }} />
          ))}
        </div>}
        {foreground}
        {/* 상호작용 레이어 — 가시영역(이미지∩존) 기준 이동/핸들. 커버 이미지도 핸들이 존 모서리에 잡힘 */}
        {!hideChrome && ordered.map(({ r, i }) => {
          const sel = selectedSet.has(r.id);
          const singleSel = (sel || resizeModeId === r.id) && groupIds.length < 2;
          const vx = Math.max(r.x, zx), vy = Math.max(r.y, 0);
          const vr = Math.min(r.x + r.w, zR), vb = Math.min(r.y + r.h, ch);
          const vw = Math.max(0, vr - vx), vh = Math.max(0, vb - vy);
          return (
            <React.Fragment key={(r.id || 'item') + ':' + i}>
              <div data-context-asset="push-image" data-onee-layer={foreground ? 'chrome' : undefined} style={{ position: 'absolute', left: vx * scale, top: vy * scale, width: vw * scale, height: vh * scale, zIndex: (r.z || 0) + 10, cursor: 'move', boxShadow: sel ? 'inset 0 0 0 1.5px #4E4CDB' : 'none' }}
                onPointerDown={(e) => onItemDown(e, i, 'move')}
                onContextMenu={(e) => openCtxMenu(e, i)} />
              {singleSel && CORNERS.map((c) => {
                const hx = (c[1] === 'w' ? vx : vr) * scale, hy = (c[0] === 'n' ? vy : vb) * scale;
                return <span key={c} onPointerDown={(e) => onItemDown(e, i, c)}
                  style={{ position: 'absolute', left: hx - 6, top: hy - 6, width: 11, height: 11, background: '#fff', border: '1.5px solid #4E4CDB', borderRadius: 2, cursor: c + '-resize', zIndex: (r.z || 0) + 11 }} />;
              })}
              {singleSel && onDeleteItem && <button type="button" title="이미지 삭제" aria-label="이미지 삭제"
                onPointerDown={stopDeleteEvent}
                onMouseDown={stopDeleteEvent}
                onPointerUp={(e) => deleteItem(e, i)}
                onClick={(e) => deleteItem(e, i)}
                style={{ position: 'absolute', left: Math.max(0, vr * scale - 22), top: Math.max(0, vy * scale), width: 22, height: 22, border: 'none', borderRadius: '0 0 0 4px', background: 'rgba(28,28,34,.82)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, zIndex: (r.z || 0) + 12, 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>}
            </React.Fragment>);
        })}
        {!hideChrome && groupBox && <>
          <div style={{ position: 'absolute', left: groupBox.x * scale, top: groupBox.y * scale, width: groupBox.w * scale, height: groupBox.h * scale, border: '1.5px solid #4E4CDB', boxSizing: 'border-box', pointerEvents: 'none', zIndex: 80 }} />
          {CORNERS.map((c) => {
            const hx = (c[1] === 'w' ? groupBox.x : groupBox.x + groupBox.w) * scale;
            const hy = (c[0] === 'n' ? groupBox.y : groupBox.y + groupBox.h) * scale;
            return <span key={'group-' + c} title="선택 묶음 크기 조정" onPointerDown={(e) => onGroupHandleDown(e, c)}
              style={{ position: 'absolute', left: hx - 7, top: hy - 7, width: 13, height: 13, background: '#fff', border: '2px solid #4E4CDB', borderRadius: 2, cursor: c + '-resize', zIndex: 81, boxSizing: 'border-box' }} />;
          })}
        </>}
        {!hideChrome && ctxMenu && (
          <div style={{ position: 'absolute', left: ctxMenu.x, top: ctxMenu.y, background: '#fff', border: '1px solid #e7e7ee', borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,.14)', zIndex: 9999, padding: '4px 0', minWidth: 140 }}
            onPointerDown={(e) => e.stopPropagation()}>
            <button style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '7px 14px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, color: '#1c1c22', textAlign: 'left' }}
              onPointerDown={() => changeZ(ctxMenu.idx, 'front')}>
              <svg width="14" height="14" 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 style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '7px 14px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, color: '#1c1c22', textAlign: 'left' }}
              onPointerDown={() => changeZ(ctxMenu.idx, 'back')}>
              <svg width="14" height="14" 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 style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '7px 14px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, color: '#1c1c22', textAlign: 'left' }}
              onPointerDown={(e) => resizeItem(e, ctxMenu.idx)}>
              <svg width="14" height="14" 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>
            {onDeleteItem && <button style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '7px 14px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, color: '#c0392b', textAlign: 'left' }}
              onPointerDown={(e) => deleteItem(e, ctxMenu.idx)}>
              <svg width="14" height="14" 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>
        )}
      </div>);
  }

  /* ── 렌더러: 3층 — 배경(뒤) / 누끼 이미지(가운데·드래그) / 그라데이션·문구(앞) ──
     · 편집 중에도 이미지는 그라데이션·문구 "아래"(뒤), 이미지존 밖으로는 클립. 저장은 전체 합성. */
  function BannerPush({ cand, form, engine, scale = 1, gradRatio, gradOpacity, rects: editedRects, onRectsChange, selectedId, onSelectRect, onBeginEdit, scorer, onDeleteRect, imageAdjustBySource, onSelectionChange }) {
    const bgRef = usePR(null);
    const frontRef = usePR(null);
    const AP = engine || window.AP_PUSH;
    const f = Object.assign({}, form || {});
    if (gradRatio != null) f.gradientRatio = gradRatio;
    if (gradOpacity != null) f.gradientOpacity = gradOpacity;
    if (imageAdjustBySource) f.imageAdjustBySource = imageAdjustBySource;
    const CW = AP ? AP.CW : 720, CH = AP ? AP.CH : 380;
    const dispW = 520, H = dispW * CH / CW;
    const editable = !!onRectsChange;
    const rects = (editedRects && editedRects.length ? editedRects : (cand && cand.rects)) || [];
    usePE(() => {
      if (!cand || !AP) return;
      let cancelled = false;
      const draw = async () => {
        try { if (document.fonts && document.fonts.ready) await document.fonts.ready; } catch (e) {}
        if (cancelled) return;
        if (editable) {
          if (bgRef.current) { const b = specFrom(cand, f, true, 2); b.layer = 'back'; b.rects = []; AP.renderBanner(bgRef.current, b); }
          if (!cancelled && frontRef.current) { const fr = specFrom(cand, f, true, 2); fr.layer = 'front'; fr.rects = []; AP.renderBanner(frontRef.current, fr); }
        } else if (bgRef.current) {
          const s = specFrom(cand, f, true, 2); s.rects = rects; AP.renderBanner(bgRef.current, s);
        }
      };
      const timer = setTimeout(draw, 80);
      return () => { cancelled = true; clearTimeout(timer); };
    }, [cand && cand.id, JSON.stringify(f), editable, editable ? '' : JSON.stringify(rects)]);
    const zoneOpts = pushGuidePatternFor(f);
    const z = AP ? AP.zones(zoneOpts) : { imgX: 252, imgW: 468 };
    const zscale = dispW / CW;
    const cst = { position: 'absolute', left: 0, top: 0, width: dispW + 'px', height: H + 'px', display: 'block' };
    return (
      <div style={{ position: 'relative', width: dispW + 'px', height: H + 'px' }}>
        <canvas ref={bgRef} style={{ ...cst, border: '1px solid #e7e7ee' }} />
        {editable && <div style={{ ...cst }}>
          <PushEditStage rects={rects} dispW={dispW} cw={CW} ch={CH} imgZone={z} bounds={cand && cand.bounds} scorer={scorer} selectedId={selectedId} onSelect={onSelectRect} onChange={onRectsChange} onBegin={onBeginEdit} onDeleteItem={onDeleteRect} imageAdjust={f.imageAdjust} imageAdjustBySource={imageAdjustBySource || f.imageAdjustBySource} onSelectionChange={onSelectionChange} />
        </div>}
        {editable && <canvas ref={frontRef} style={{ ...cst, pointerEvents: 'none' }} />}
        {/* [확인용] 65% 이미지존 표시 — 미리보기 전용(출력 JPG엔 안 나옴). 숨김(복원 시 false 제거). */}
        {false &&
        <div style={{ position: 'absolute', left: (z.imgX * zscale) + 'px', top: 0, width: (z.imgW * zscale) + 'px', height: H + 'px', border: '1.5px dashed #e11d48', background: 'rgba(225,29,72,0.06)', pointerEvents: 'none', zIndex: 60, boxSizing: 'border-box' }}>
          <span style={{ position: 'absolute', top: '2px', left: '4px', fontSize: '10px', fontWeight: 700, color: '#e11d48', background: 'rgba(255,255,255,0.9)', padding: '0 4px', borderRadius: '3px', whiteSpace: 'nowrap' }}>{'이미지존 ' + Math.round(z.imgW / CW * 100) + '% (' + Math.round(z.imgW) + '×' + CH + ')'}</span>
        </div>
        }
      </div>);
  }

  /* ── 후보 썸네일: 이미지존만 ── */
  function PushThumb({ cand, form, engine, scale = 1 }) {
    const ref = usePR(null);
    const AP = engine || window.AP_PUSH;
    const zoneOpts = pushGuidePatternFor(form);
    usePE(() => {
      if (!ref.current || !cand || !AP) return;
      AP.renderPlacement(ref.current, { ...zoneOpts, bgColor: (form && form.bgColor) || pushBgOptions()[0].css, rects: cand.rects || [], scale: Math.max(2, Math.round(scale * 2)) });
    }, [cand && cand.id, cand && JSON.stringify(cand.rects), form && form.bgColor, form && form.type, form && form.sticker, scale]);
    const z = AP ? AP.zones(zoneOpts) : { imgW: 468, imgH: 380 };
    const zw = z.imgW * scale;
    const zh = z.imgH * scale;
    return <canvas ref={ref} style={{ width: zw + 'px', height: zh + 'px', border: '1px solid #e7e7ee', display: 'block' }} />;
  }

  /* ── 우측 인스펙터: 이미지유형 · 로고 · 문구(2줄10자) · 색/정렬 · 컬러칩(누끼) ── */
  function PushInspector({ form, patchForm, schema, items = [], srcs = [], imageAdjust, onImageAdjust, outpaintSource, initialVariants = [], onVariantsChange, onApplyOutpaint, showToast = () => {}, onUseGuideImage = null, guidePlaceholderImage = null }) {
    const f = form || {};
    const [pickBusy, setPickBusy] = useS2(false);
    const colabRef = usePR(null), progRef = usePR(null), brandRef = usePR(null);
    const SCH = schema || window.SCHEMA_PUSH || {};
    const imageTypes = SCH.imageTypes || [{ id: 'editorial', label: '화보컷' }, { id: 'nukki', label: '누끼컷' }];
    const bgOptions = pushBgOptions();
    const _in = { width: '100%', border: '1px solid #e7e7ee', borderRadius: '8px', padding: '7px 9px', font: 'inherit', fontSize: '12.5px', marginTop: '4px', boxSizing: 'border-box' };
    const _lb = { fontSize: '11.5px', fontWeight: 700, color: '#43434e', marginTop: '12px', display: 'block' };
    const seg = (on) => ({ flex: 1, padding: '7px 4px', borderRadius: '8px', fontFamily: 'inherit', fontSize: '11.5px', fontWeight: 700, cursor: 'pointer', border: on ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: on ? '#eef2ff' : '#fff', color: on ? '#4E4CDB' : '#6b6b78' });
    const pick = async (files, key) => { const u = Array.from(files || []).filter((x) => x.type.startsWith('image/')); if (u.length) patchForm({ [key]: await readAsDataURL(u[0]) }); };
    const curType = (f.imageType || 'editorial');
    const copyLimit = pushCopyLimits(SCH, f);
    const normalizedCopy = normalizePushCopy(f.copy || '', copyLimit.lines, copyLimit.chars);
    const copyLines = normalizedCopy ? normalizedCopy.split(NL) : [];
    const copyFields = Array.from({ length: copyLimit.lines }, (_, i) => copyLines[i] || '');
    const copyLens = copyFields.map((line) => pushCopyVisibleLen(line));
    const copyMeta = copyLens.map((n, i) => (i + 1) + '줄 ' + n + '/' + copyLimit.chars).join(' · ');
    const selectedType = (SCH.types || []).find((type) => type.id === (f.type || 'basic'));
    const selectedTypeLabel = (selectedType && selectedType.label) || '기본형';
    const patchCopyLine = (idx, raw) => {
      const src = String(raw || '').replace(/\r/g, '');
      const pasted = src.split(NL);
      const next = copyFields.slice();
      if (pasted.length > 1) pasted.slice(0, copyLimit.lines - idx).forEach((line, off) => { next[idx + off] = normalizePushCopyLineInput(line, copyLimit.chars); });
      else next[idx] = normalizePushCopyLineInput(src, copyLimit.chars);
      patchForm({ copy: buildPushCopyFromInputs(next, copyLimit.chars, copyLimit.lines) });
    };
    const logoMode = normalizePushLogoMode(f.logoMode);
    const logoOptions = pushLogoOptions();
    // 방송시간 타입 옵션(스키마에서) — 배지 LIVE/TV · 형식 힌트
    const atType = (SCH.types || []).find((x) => x.id === 'airtime');
    const atRow = (atType && atType.airtimeRow) || {};
    const badgeOpts = atRow.colors ? Object.keys(atRow.colors) : ['LIVE', 'TV'];
    const timePh = (atRow.example && atRow.example.time) || '05/10 (수) 20:00';
    const timeFmt = atRow.timeFormat || 'MM/DD (요일) 시:분';
    const TIME_OPTS = []; for (let h = 0; h < 24; h++) { const hh = (h < 10 ? '0' : '') + h; TIME_OPTS.push(hh + ':00'); TIME_OPTS.push(hh + ':30'); }
    const applyPickedColor = (hex) => {
      const picked = normHex(hex);
      if (!picked) return false;
      const grad = pushGradientFromHex(picked) || nearestPushBgOption(picked);
      patchForm({ bgColor: grad.css, gradColor: picked, colorAuto: false });
      return true;
    };
    const pickScreenColor = async () => {
      if (!window.EyeDropper) { showToast('이 브라우저는 화면 스포이드를 지원하지 않아요'); return; }
      try {
        const res = await new window.EyeDropper().open();
        if (applyPickedColor(res && res.sRGBHex)) showToast('스포이드 색을 적용했습니다');
      } catch (e) {}
    };
    const extractPhotoColor = async () => {
      const photoItem = (items || []).find((x) => x.photoKind === 'photo');
      const src = (photoItem && photoItem.src) || srcs[0];
      if (!src) { showToast('색을 추출할 이미지를 먼저 올려주세요'); return; }
      setPickBusy(true);
      try {
        const st = window.__1ETopColor && window.__1ETopColor.photoStats ? await window.__1ETopColor.photoStats(src) : null;
        const hex = (st && st.hex) || await photoBgColor1E(src) || await extractDominantColor(src);
        if (applyPickedColor(hex)) showToast('이미지 색을 배경에 적용했습니다');
      } catch (e) {
        showToast('이미지 색 추출에 실패했어요');
      } finally {
        setPickBusy(false);
      }
    };

    return (
      <aside className="ap-controls" style={{ borderLeft: '1px solid #ececf2', borderRight: 'none' }}>
        <div style={{ fontSize: '11px', fontWeight: 800, color: '#9a9aa6', letterSpacing: '0.04em', marginBottom: '10px' }}>내용 · {selectedTypeLabel} <span style={{ color: '#4E4CDB', fontWeight: 800 }}>앱푸시</span></div>

        {window.BannerImageTools && window.BannerImageTools.OutpaintPanel && <div onPointerDown={(e) => e.stopPropagation()}>{React.createElement(window.BannerImageTools.OutpaintPanel, { source: outpaintSource, initialVariants, onVariantsChange, onApply: onApplyOutpaint })}</div>}
        {window.BannerImageTools && <div onPointerDown={(e) => e.stopPropagation()}>{React.createElement(window.BannerImageTools.Panel, { value: imageAdjust, onChange: onImageAdjust, disabled: !srcs.length })}</div>}

        <section className="ap-sec">
          {/* [되돌림] 화보/누끼 토글은 제거 — 화보/누끼 UI는 나중에 누끼 로컬엔진 붙이면서 재설계 */}
          {/* CJONSTYLE 로고 */}
          <label style={{ ..._lb, marginTop: 0 }}>CJONSTYLE 로고 <span style={{ color: '#9a9aa6', fontWeight: 500 }}>(좌상단 고정)</span></label>
          <div role="radiogroup" aria-label="CJONSTYLE 로고" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '6px', marginTop: '6px' }}>
            {logoOptions.map((opt) => {
              const on = logoMode === opt.id;
              return (
                <label key={opt.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '5px', minWidth: 0, padding: '7px 4px', borderRadius: '8px', border: on ? '1.5px solid #4E4CDB' : '1px solid #e7e7ee', background: on ? '#eef2ff' : '#fff', color: on ? '#4E4CDB' : '#6b6b78', font: 'inherit', fontSize: '11.5px', fontWeight: 800, cursor: 'pointer' }}>
                  <input type="radio" name="push-logo-mode" value={opt.id} checked={on} onChange={() => patchForm({ logoMode: opt.id, logo: null, logoScale: null })} style={{ width: 13, height: 13, accentColor: '#4E4CDB', margin: 0 }} />
                  <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{opt.label}</span>
                </label>);
            })}
          </div>

          {/* 콜라보 로고 (선택) */}
          <label style={_lb}>콜라보 로고 (우상단·선택)</label>
          <input ref={colabRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => { pick(e.target.files, 'colabLogo'); e.target.value = ''; }} />
          <button onClick={() => colabRef.current && colabRef.current.click()} style={{ ..._in, cursor: 'pointer', textAlign: 'left', color: f.colabLogo ? '#1c1c22' : '#9a9aa6' }}>{f.colabLogo ? '콜라보 로고 선택됨 · 변경' : (f.colabLogo ? '' : '콜라보 로고 업로드…')}{f.colabLogo && <span onClick={(e) => { e.stopPropagation(); patchForm({ colabLogo: null }); }} style={{ float: 'right', color: '#c0392b' }}>제거</span>}</button>
          {window.AssetLibraryField && <div style={{ marginTop: '6px' }}><AssetLibraryField kind="logo" max={1} onApply={(assets) => patchForm({ colabLogo: assets[0] && assets[0].src })} compact /></div>}

          {/* 메인 문구 2줄 */}
          <label style={_lb}>메인 문구 <span style={{ color: '#9a9aa6', fontWeight: 500 }}>({copyLimit.lines}줄 · 줄당 최대 {copyLimit.chars}자 · 공백 제외 · {copyMeta})</span></label>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '6px', marginTop: '4px' }}>
            {copyFields.map((value, i) => <input key={'copy-' + i} value={value} onChange={(e) => patchCopyLine(i, e.target.value)} maxLength={pushCopyInputMaxLength(copyLimit.chars)} placeholder={i === 0 ? '위 문구' : i === 1 ? '아래 문구' : (i + 1) + '줄 문구'} style={{ ..._in, marginTop: 0 }} />)}
          </div>

          {/* 방송시간 타입 전용 필드 (문구 아래 [배지+방송시간] 행) */}
          {f.type === 'airtime' && <React.Fragment>
            <label style={_lb}>방송 유형 배지</label>
            <div style={{ display: 'flex', gap: '6px' }}>
              {badgeOpts.map((opt) => {
                const on = (f.badge || 'LIVE') === opt; const c = (atRow.colors && atRow.colors[opt]) || '#640FAF';
                return <button key={opt} onClick={() => patchForm({ badge: opt })} style={{ ...seg(on), borderColor: on ? c : '#e7e7ee', background: on ? c : '#fff', color: on ? '#fff' : '#6b6b78' }}>{opt}</button>;
              })}
            </div>
            <label style={_lb}>방송 일시 <span style={{ color: '#9a9aa6', fontWeight: 500 }}>(요일 자동)</span></label>
            <input type="date" value={f.airtimeDate || ''} onChange={(e) => patchForm({ airtimeDate: e.target.value })} style={{ ..._in, cursor: 'pointer' }} />
            <label style={_lb}>방송 시간</label>
            <select value={f.airtimeTime || ''} onChange={(e) => patchForm({ airtimeTime: e.target.value })} style={{ ..._in, cursor: 'pointer' }}>
              <option value="">시간 선택</option>
              {TIME_OPTS.map((t) => <option key={t} value={t}>{t}</option>)}
            </select>
            {f.airtimeDate && <div style={{ fontSize: '12px', color: '#4E4CDB', fontWeight: 700, marginTop: '7px' }}>{composeAirtime(f.airtimeDate, f.airtimeTime) || '—'}</div>}
          </React.Fragment>}

          {/* 로고형·리딩콘텐츠형: 프로그램 로고(문구 위) */}
          {(f.type === 'logo' || f.type === 'leading') && <React.Fragment>
            <label style={_lb}>프로그램 로고 (문구 위·필수)</label>
            <div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}>
              {[['image', '이미지'], ['text', '텍스트']].map(([id, lb]) =>
                <button key={id} onClick={() => patchForm({ programLogoKind: id })} style={seg((f.programLogoKind || 'image') === id)}>{lb}</button>)}
            </div>
            <input ref={progRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => { pick(e.target.files, 'programLogo'); e.target.value = ''; }} />
            {(f.programLogoKind || 'image') === 'image'
              ? <button onClick={() => progRef.current && progRef.current.click()} style={{ ..._in, cursor: 'pointer', textAlign: 'left', color: f.programLogo ? '#1c1c22' : '#9a9aa6', background: f.programLogo ? '#eef2ff' : '#fff', borderColor: f.programLogo ? '#4E4CDB' : '#e7e7ee' }}>{f.programLogo ? '프로그램 로고 선택됨 · 변경' : '프로그램 로고 업로드…'}{f.programLogo && <span onClick={(e) => { e.stopPropagation(); patchForm({ programLogo: null }); }} style={{ float: 'right', color: '#c0392b' }}>제거</span>}</button>
              : <input value={f.programLogoText || ''} onChange={(e) => patchForm({ programLogoText: e.target.value })} maxLength={18} placeholder="예: 최화정 SHOW" style={_in} />}
            {(f.programLogoKind || 'image') === 'image' && window.AssetLibraryField && <div style={{ marginTop: '6px' }}><AssetLibraryField kind="logo" max={1} onApply={(assets) => patchForm({ programLogo: assets[0] && assets[0].src })} compact /></div>}
            <div style={{ marginTop: '8px' }}>
              <MiniSlider label="프로그램 로고 크기" value={f.programLogoScale || 100} min={60} max={180} step={1} fmt={(v) => Math.round(v) + '%'} onChange={(v) => patchForm({ programLogoScale: v })} />
              <MiniSlider label="프로그램 로고 가로 위치" value={f.programLogoX || 0} min={-60} max={180} step={1} fmt={(v) => Math.round(v) + 'px'} onChange={(v) => patchForm({ programLogoX: v })} />
              <MiniSlider label="프로그램 로고 세로 위치" value={f.programLogoY || 0} min={-80} max={120} step={1} fmt={(v) => Math.round(v) + 'px'} onChange={(v) => patchForm({ programLogoY: v })} />
            </div>
          </React.Fragment>}

          {/* 리딩콘텐츠형: 시간 스티커(선택) */}
          {f.type === 'leading' && <React.Fragment>
            <label style={_lb}>시간 스티커 <span style={{ color: '#9a9aa6', fontWeight: 500 }}>(우상단 빨강 원 · 선택)</span></label>
            <input value={f.sticker || ''} onChange={(e) => patchForm({ sticker: e.target.value })} placeholder="예: 매주 월 21시" style={_in} />
          </React.Fragment>}

          {/* 로고강조: 우상단 "오늘혜택좋은+브랜드" 한 이미지 (콜라보로고 슬롯) */}
          {f.type === 'logohit' && <React.Fragment>
            <label style={_lb}>브랜드 로고 <span style={{ color: '#9a9aa6', fontWeight: 500 }}>(우상단 · "오늘혜택좋은+브랜드" 한 이미지)</span></label>
            <input ref={brandRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => { pick(e.target.files, 'brandLogo'); e.target.value = ''; }} />
            <button onClick={() => brandRef.current && brandRef.current.click()} style={{ ..._in, cursor: 'pointer', textAlign: 'left', color: f.brandLogo ? '#1c1c22' : '#9a9aa6', background: f.brandLogo ? '#eef2ff' : '#fff', borderColor: f.brandLogo ? '#4E4CDB' : '#e7e7ee' }}>{f.brandLogo ? '브랜드 로고 선택됨 · 변경' : '브랜드 로고 업로드…'}</button>
            {window.AssetLibraryField && <div style={{ marginTop: '6px' }}><AssetLibraryField kind="logo" max={1} onApply={(assets) => patchForm({ brandLogo: assets[0] && assets[0].src })} compact /></div>}
          </React.Fragment>}

          {/* 문구/프로그램 로고 색 (#333 / #fff) */}
          <label style={_lb}>문구/프로그램 로고 색</label>
          <div style={{ display: 'flex', gap: '6px' }}>
            {[['#333333', '진한색'], ['#ffffff', '흰색']].map(([hex, lb]) =>
              <button key={hex} onClick={() => patchForm({ textColor: hex, programLogoColor: hex })} style={seg((f.textColor || '#333333') === hex && (f.programLogoColor || f.textColor || '#333333') === hex)}>{lb}</button>)}
          </div>

          {/* 배경 · 그라데이션 색 — 이미지색 자동 + 컬러칩 수동 선택(기술상단과 동일) */}
          <div style={{ ..._lb, display: 'flex', alignItems: 'center', gap: '6px' }}>
            배경 · 그라데이션
            <span style={{ fontSize: '10px', fontWeight: 700, padding: '1px 6px', borderRadius: '5px', background: (f.colorAuto !== false) ? '#eef2ff' : '#f0f0f3', color: (f.colorAuto !== false) ? '#4E4CDB' : '#8a8a96' }}>{(f.colorAuto !== false) ? '자동' : '수동'}</span>
            {(f.colorAuto === false) && <button onClick={() => patchForm({ colorAuto: true })} style={{ marginLeft: 'auto', border: 'none', background: 'none', color: '#4E4CDB', fontSize: '10.5px', fontWeight: 700, cursor: 'pointer', padding: 0 }}>자동으로</button>}
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px' }}>
            {/* [숨김] 선택 컬러칩 위 네모 스와치 — 복원 시 false 제거 */}
            <span style={{ width: '26px', height: '26px', borderRadius: '7px', flex: '0 0 auto', background: f.bgColor || bgOptions[0].css, border: '1px solid #e7e7ee' }} />
            <span style={{ fontSize: '11.5px', color: '#8a8a96' }}>{pushBgLabel(f.bgColor || bgOptions[0].css)}</span>
          </div>
          {bgOptions.length > 0 && <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
            {bgOptions.map((opt) => {
              const on = samePushBg(f.bgColor || bgOptions[0].css, opt.css);
              return <button key={opt.id || opt.css} onClick={() => patchForm({ bgColor: opt.css, gradColor: opt.from || opt.solid || firstHex(opt.css), colorAuto: false })} title={opt.css} aria-label={opt.label} style={{ width: '26px', height: '26px', borderRadius: '50%', background: opt.css, cursor: 'pointer', padding: 0, border: on ? '2px solid #1c1c22' : '2px solid #e7e7ee', boxShadow: on ? '0 0 0 2px #fff inset' : 'none' }} />;
            })}
          </div>}
          <div style={{ display: 'grid', gridTemplateColumns: onUseGuideImage ? '1fr 1fr 1fr' : '1fr 1fr', gap: '6px', marginTop: '8px' }}>
            <button type="button" onClick={pickScreenColor} disabled={!window.EyeDropper} title={window.EyeDropper ? '화면에서 색을 직접 선택' : 'Chrome/Edge 등 EyeDropper 지원 브라우저에서 사용 가능'} style={{ ...seg(false), opacity: window.EyeDropper ? 1 : 0.48 }}>스포이드</button>
            <button type="button" onClick={extractPhotoColor} disabled={pickBusy} style={seg(false)}>{pickBusy ? '추출 중…' : '이미지색 적용'}</button>
            {onUseGuideImage && <button type="button" onClick={onUseGuideImage} title={(guidePlaceholderImage && guidePlaceholderImage.note) || '앱푸시 가이드 이미지 추가'} style={seg(false)}>가이드 이미지</button>}
          </div>
          {curType === 'nukki' && <div style={{ fontSize: '11px', color: '#9a9aa6', marginTop: '5px' }}>누끼 배경 · 흰배경 금지</div>}
        </section>
      </aside>);
  }

  /* ── 저장 스펙 (preview 끔 + 고해상) ── */
  function buildSaveSpec({ cand, form, rects, gradientRatio, gradientOpacity }) {   // 출력 = 정확히 720×380 (스케일 1)
    const f = Object.assign({}, form || {});
    if (gradientRatio != null) f.gradientRatio = gradientRatio;
    if (gradientOpacity != null) f.gradientOpacity = gradientOpacity;
    const s = specFrom(cand, f, false, 1);
    if (rects && rects.length) s.rects = rects;   // 편집된 위치/크기 반영
    return s;
  }

  function buildPhotoCutoutCandidates(AP, photoBg, nukkiFg, opts) {
    if (!AP || !photoBg.length || nukkiFg.length < 2) return [];
    const photoBases = AP.generateEditorialCandidates ? (AP.generateEditorialCandidates(photoBg, opts) || []) : [];
    const cutoutBases = AP.generateNukkiCandidates ? (AP.generateNukkiCandidates(nukkiFg, null, opts) || []) : [];
    if (!photoBases.length || !cutoutBases.length) return [];
    const out = [];
    photoBases.slice(0, 3).forEach((pb, pi) => {
      cutoutBases.slice(0, 10).forEach((cb, ci) => {
        const pRects = (pb.rects || []).map((r, ri) => Object.assign({}, r, {
          id: r.id || ('photo-bg-' + pi + '-' + ri),
          role: r.role || 'photo',
          photoKind: 'photo',
          fit: r.fit || 'cover',
          cover: r.cover || true,
          z: r.z != null ? r.z : 1,
        }));
        const cRects = (cb.rects || []).map((r) => Object.assign({}, r, {
          z: (r.z || 0) + 20,
          photoKind: r.photoKind || 'nukki',
        }));
        const ps = pb.scoring && Number.isFinite(pb.scoring.score) ? pb.scoring.score : 1;
        const cs = cb.scoring && Number.isFinite(cb.scoring.score) ? cb.scoring.score : 1;
        out.push(Object.assign({}, cb, {
          id: 'photo-cutout-' + pi + '-' + ci,
          strategy: 'photo-cutout',
          strategyLabel: '화보 + 누끼 다중',
          rowsLabel: (cb.rowsLabel || (nukkiFg.length + '개')) + ' · 화보 배경',
          rects: pRects.concat(cRects),
          bounds: pb.bounds || cb.bounds,
          scoring: { score: Math.min(1, (ps + cs) / 2) },
          _render: Object.assign({}, pb._render || {}, { type: 'editorial', gradient: true }),
        }));
      });
    });
    return out;
  }

  /* ── 콘텐츠 기반 후보 (이미지유형 반영) ── */
  function buildCandidates({ AP, allItems, productItems, modelItems, modelItem, sc, form }) {
    if (!allItems.length) return [];
    const it = (form && form.imageType) || 'editorial';
    const opts = Object.assign({}, sc || {}, pushGuidePatternFor(form));
    const SCH = window.SCHEMA_PUSH || {};
    const padY = (SCH.slots && SCH.slots.model && SCH.slots.model.pad && SCH.slots.model.pad.y) || 24;
    const td = (SCH.types || []).find((x) => x.id === (form && form.type));
    const nukkiTop = (opts.imgTop || 0) + padY;
    const isLeading = !!(form && form.type === 'leading');
    // 인물 누끼 우하단 앵커(기본형 model-corner) — 인물을 이미지존 우하단에 붙이고, 우측 rightCropFrac(스키마 slots.model.nukkiAnchor)만큼
    //   프레임 밖으로 넘겨 프레임이 자름(소스 크롭 X = 이미지 원본은 안 자름). 하체도 프레임 아래로 넘겨 상체 중심.
    //   기본형: 제품 + 인물 1장 → 인물 우하단(제품 앞). 리딩콘텐츠형: 인물 1장만 올려도 동일 배치(제품 없어도 인물만 우하단 앵커).
    const mAnchor = (td && td.modelPlacement) || (SCH.slots && SCH.slots.model && SCH.slots.model.nukkiAnchor);
    const hasSticker = isLeading && !!String((form && form.sticker) || '').trim();   // 리딩 + 시간스티커 붙였을 때
    // (신규) 화보(사진) + 인물 누끼(컷아웃 1장) → 화보를 뒤(cover 배경), 인물 누끼를 앞 우하단 앵커.
    //   분류(pose)로는 화보 속 인물도 model 로 잡혀 둘 다 모델→패션이 되므로, 투명도(photoKind)로 사진/컷아웃을 갈라 라우팅.
    const photoBg = allItems.filter((x) => x.photoKind === 'photo');          // 화보(불투명 사진) = 배경
    const nukkiFg = allItems.filter((x) => x.photoKind !== 'photo');          // 컷아웃(투명 누끼)
    const photoCutout = buildPhotoCutoutCandidates(AP, photoBg, nukkiFg, opts);
    if (photoCutout.length) {
      const photoOnly = AP.generateEditorialCandidates ? (AP.generateEditorialCandidates(photoBg, opts) || []) : [];
      const cutoutOnly = AP.generateNukkiCandidates ? (AP.generateNukkiCandidates(nukkiFg, null, opts) || []) : [];
      const editorialFallback = AP.generateEditorialCandidates ? (AP.generateEditorialCandidates(allItems, opts) || []) : [];
      return photoCutout.concat(photoOnly, cutoutOnly, editorialFallback);
    }
    if (mAnchor && AP.generateModelCornerCandidates && photoBg.length >= 1 && nukkiFg.length === 1) {
      return AP.generateModelCornerCandidates(nukkiFg, photoBg, Object.assign({}, opts, { modelPlacement: mAnchor, imageType: 'editorial' })) || [];
    }
    // 인물(모델) 결정: 비전분류가 model 이면 그것 / 리딩+스티커에서 단일 업로드면 분류가 안 돼도(LLM 미연결 등) 그 한 장을 인물로 간주.
    const cornerPersons = (modelItems.length === 1) ? modelItems : ((hasSticker && allItems.length === 1) ? allItems.slice(0, 1) : null);
    // 우하단 앵커 + 우측 프레임밖 잘림(model-corner)은 "항상"이 아니라 두 경우에만:
    //   ① 상품 누끼 + 인물 누끼 함께 업로드(유형 무관·기본형 포함) → 인물을 제품 앞 우하단에.
    //   ② 리딩콘텐츠 + 시간스티커 붙였을 때(인물 있음). 그 외(리딩이라도 스티커 없이 인물만)는 원래대로 패션 배치.
    if (mAnchor && AP.generateModelCornerCandidates && cornerPersons && (productItems.length >= 1 || hasSticker)) {
      const cornerProducts = (modelItems.length === 1) ? productItems : [];
      return AP.generateModelCornerCandidates(cornerPersons, cornerProducts, Object.assign({}, opts, { modelPlacement: mAnchor, imageType: it })) || [];
    }
    // 인물 사진(editorial)만 있으면 → 550 A식 "패션 배치"(상체 크롭·머리끝 상단 정렬·하체 크롭). 머리끝은 nukkiTop 에 맞춰짐.
    if (modelItems.length && !productItems.length) return AP.generateFashionCandidates(modelItems, opts) || [];
    if (it === 'nukki') {
      if (productItems.length) return AP.generateNukkiCandidates(productItems, modelItem, opts) || [];
      return AP.generateNukkiCandidates(allItems, null, opts) || [];
    }
    // 화보/라운드 = 사진 cover (+ 인물이면 머리끝을 누끼 상단 여백 nukkiTop 에 정렬 → 인물 최상단 배치)
    const headBySrc = {};
    modelItems.forEach((m) => { if (m.src && m.headTopFrac != null) headBySrc[m.src] = m.headTopFrac; });
    const enriched = allItems.map((x) => (x.src && headBySrc[x.src] != null) ? { ...x, headTopFrac: headBySrc[x.src] } : x);
    const heroHead = modelItems[0] && modelItems[0].headTopFrac;
    const ed = AP.generateEditorialCandidates(enriched, Object.assign({}, opts, { headTopFrac: heroHead, headTargetTop: nukkiTop })) || [];
    return ed.length ? ed : (AP.generateNukkiCandidates(allItems, null, opts) || []);
  }

  // 대표색 → 컬러칩 중 색상(hue) 가장 가까운 것
  function nearestChip(hex, chips) {
    const C = __APColorLocal; if (!C || !chips || !chips.length) return chips && chips[0];
    const hs = C.hsl(C.hexToRgb(hex));
    let best = chips[0], bd = Infinity;
    chips.forEach((c) => { const cc = C.hsl(C.hexToRgb(c)); let dh = Math.abs(hs[0] - cc[0]); if (dh > 180) dh = 360 - dh; if (dh < bd) { bd = dh; best = c; } });
    return best;
  }
  // 추출색 → CJ 컬러칩(기본). 저채도·추출 실패 시 중립 그레이 칩.
  function chipFromImageHex(hex, chips) {
    const C = __APColorLocal;
    const gray = (pushBgOptions()[0] && pushBgOptions()[0].solid) || '#D3D3D3';
    if (!hex) return gray;
    if (C) {
      const s = C.hsl(C.hexToRgb(hex))[1];
      if (s < 0.1) return gray;
    }
    return nearestChip(hex, chips) || gray;
  }
  // 이미지 색 → CJ 컬러칩 → 배경·그라데이션 (누끼=대표색 / 화보=사진 가장자리 톤 → 동일 매핑)
  async function deriveColors(src, ctx) {
    if (ctx && ctx.form && ctx.form.colorAuto === false) return null;   // 수동 선택 중이면 자동 덮어쓰기 안 함
    const C = __APColorLocal; if (!C || !src) return null;
    const it = (ctx && ctx.form && ctx.form.imageType) || 'editorial';
    const usePhotoTone = !!(ctx && ctx.hasPhoto) || it !== 'nukki';
    const stats = usePhotoTone && C.photoStats ? await C.photoStats(src) : null;
    const hex = usePhotoTone ? ((stats && stats.hex) || await C.photoBg(src)) : await C.dominantVivid(src);
    if (usePhotoTone) {
      const custom = pushGradientFromHex(hex);
      if (custom) return { bgColor: custom.css, gradColor: custom.solid || firstHex(custom.css) };
    }
    const opt = nearestPushBgOption(hex);
    return { bgColor: opt.css, gradColor: opt.from || opt.solid || firstHex(opt.css) };
  }

  const PROFILE_PUSH = {
    get engine() { return window.AP_PUSH; },
    get schema() { return window.SCHEMA_PUSH; },
    get bannerTypes() {
      const t = (window.SCHEMA_PUSH && window.SCHEMA_PUSH.types) || [];
      const on = t.filter((x) => x.enabled !== false);   // enabled:false 인 유형은 목록에서 숨김(코드는 유지)
      return on.length ? on.map((x) => ({ id: x.id, label: x.label, sub: x.note || '', ready: true })) : [{ id: 'basic', label: '기본', ready: true }];
    },
    get colorchips() { return pushBgOptions().map((x) => x.css); },
    initialType: 'basic',
    // 550식 이미지 자동판별 → imageType 세팅 (투명=누끼 / 사진=화보). 토글 없음.
    onAutoKind: (kind, { patchForm }) => patchForm({ imageType: kind === 'photo' ? 'editorial' : 'nukki' }),
    title: '앱푸시',
    pngLabel: 'JPG 저장 (720×380)',
    defaultFileName: '앱푸시배너',
    saveMime: 'image/jpeg',
    saveQuality: 1.0,
    saveExt: '.jpg',
    initialForm: { imageType: 'editorial', copy: '이미지내 문구\n최대 10자', logoMode: 'purple', colabLogo: null, textColor: '#333333', align: 'top', bgColor: PUSH_GUIDE_FALLBACK_BG.css, gradColor: PUSH_GUIDE_FALLBACK_BG.from, colorAuto: true },
    Banner: BannerPush,
    Thumb: PushThumb,
    Inspector: PushInspector,
    buildSaveSpec: buildSaveSpec,
    buildCandidates: buildCandidates,
    deriveColors: deriveColors,   // 이미지색 → 배경/그라데이션 자동
    enhancedRemove: true,
    removeQualityPasses: STRICT_NUKKI_QUALITY_PASSES,
  };

  // [공유 셸 공개 — Phase1] 팩토리를 그대로 노출 → 다른 유형(550/H1 등)이 profile 로 같은 화면 재사용.
  window.__APEditor = AutoPlaceTyped;
  // 1E = 공유 팩토리를 profile 없이 호출(전부 1E 기본값). 옛 nukki1e-top 셸 복사본을 대체.
  //  → 무회귀(1E 기본값 동일) + nukki-push 계열 버그픽스(따로 업로드 시 인물 라벨 유실 방지 등) 반영.
  window.AutoPlace1E = function AutoPlace1E(props) {
    if (!AutoPlaceTyped) return null;
    return React.createElement(AutoPlaceTyped, {
      initialType: props && props.initialType,
      initialImages: props && props.initialImages,
      initialRecords: props && props.initialRecords,
      initialCategory: props && props.initialCategory,
      restore: props && props.restore,
      onRestoreConsumed: props && props.onRestoreConsumed,
      ga4Source: props && props.ga4Source,
    });
  };

  window.AutoPlacePush = function AutoPlacePush(props) {
    if (!AutoPlaceTyped) return null;
    // 배너 만들기 카드가 넘긴 유형(guide.pushType) → 에디터 초기 유형. 없으면 profile 기본(basic).
    return React.createElement(AutoPlaceTyped, { profile: PROFILE_PUSH, initialType: props && props.initialType, initialImages: props && props.initialImages, initialRecords: props && props.initialRecords, initialCategory: props && props.initialCategory, restore: props && props.restore, onRestoreConsumed: props && props.onRestoreConsumed });
  };

})();
