/* ============================================================
   generator.jsx — Google Gemini (Nano Banana) outpainting via
   Cloudflare Worker proxy + high-quality local fallback.
   ============================================================ */

// Render scale — banners are generated and exported at this multiple of
// their nominal spec size for crispness on retina displays.
const RENDER_SCALE = 3;

/* ------------------------------------------------------------
   BANNER_SPECS — 공식 safe-area 가이드 기준
   ------------------------------------------------------------
   source: example_variation_size.xlsx (사용자 제공)
   w / h        : 표시 좌표(=production / RENDER_SCALE). 실제 출력 = w·SCALE × h·SCALE
   allowed.x,y,w,h : safe area 의 정규화 좌표(0~1)
   safePx       : 원본 production 픽셀 기준 safe area 박스 (디버깅/오버레이용)
   guide        : 가이드 시트의 raw 상/하/좌/우 인셋 (production 픽셀)
   ------------------------------------------------------------
   계산식: prod=W×H, 인셋 top/bot/L/R 일 때
     allowed.x = L/W,        allowed.y = top/H
     allowed.w = (W-L-R)/W,  allowed.h = (H-top-bot)/H
   ------------------------------------------------------------ */
const BANNER_SPECS = [
  {
    // production 1125×1620, safe (60,420)→(1065,1278), w=1005 h=858
    id: "BeautyHomeMainBanner",
    name: "Beauty · Home Main",
    w: 375, h: 540,
    allowed: { x: 0.05333, y: 0.25926, w: 0.89333, h: 0.52963 },
    anchor:  { x: 0.50, y: 0.50 },
    guide:   { prodW: 1125, prodH: 1620, top: 420, bottom: 342, left: 60, right: 60 },
    safePx:  { x: 60, y: 420, w: 1005, h: 858 },
    rule:    "Safe area 1005×858 (top 420 / bottom 342 / side 60 inset)",
    negSpace:"Top 420px + bottom 342px + 60px side gutters reserved for outpaint",
  },
  {
    // production 1005×1005, safe (60,384)→(945,915), w=885 h=531
    id: "PromotionCategoryBanner",
    name: "Promotion · Category",
    w: 335, h: 335,
    allowed: { x: 0.05970, y: 0.38209, w: 0.88060, h: 0.52836 },
    anchor:  { x: 0.50, y: 0.50 },
    guide:   { prodW: 1005, prodH: 1005, top: 384, bottom: 90, left: 60, right: 60 },
    safePx:  { x: 60, y: 384, w: 885, h: 531 },
    rule:    "Safe area 885×531 (top 384 / bottom 90 / side 60 inset)",
    negSpace:"Top 384px + bottom 90px + 60px side gutters reserved for outpaint",
  },
  {
    // production 450×996, safe (60,132)→(390,906), w=330 h=774
    id: "PromotionRankingBanner",
    name: "Promotion · Ranking",
    w: 150, h: 332,
    allowed: { x: 0.13333, y: 0.13253, w: 0.73333, h: 0.77711 },
    anchor:  { x: 0.50, y: 0.50 },
    guide:   { prodW: 450, prodH: 996, top: 132, bottom: 90, left: 60, right: 60 },
    safePx:  { x: 60, y: 132, w: 330, h: 774 },
    rule:    "Safe area 330×774 (top 132 / bottom 90 / side 60 inset)",
    negSpace:"Top 132px + bottom 90px + 60px side gutters reserved for outpaint",
  },
  {
    // production 591×1446, safe (60,432)→(531,1362), w=471 h=930
    id: "BeautyPromoBanner",
    name: "Beauty · Promo",
    w: 197, h: 482,
    allowed: { x: 0.10152, y: 0.29876, w: 0.79695, h: 0.64315 },
    anchor:  { x: 0.50, y: 0.50 },
    guide:   { prodW: 591, prodH: 1446, top: 432, bottom: 84, left: 60, right: 60 },
    safePx:  { x: 60, y: 432, w: 471, h: 930 },
    rule:    "Safe area 471×930 (top 432 / bottom 84 / side 60 inset)",
    negSpace:"Top 432px + bottom 84px + 60px side gutters reserved for outpaint",
  },
  {
    // production 1005×1005, safe area = entire canvas (no margins specified)
    // Falls into the isFullCanvas branch → source rendered as-is, no AI call.
    id: "Exhibition",
    name: "Exhibition",
    w: 335, h: 335,
    allowed: { x: 0, y: 0, w: 1, h: 1 },
    anchor:  { x: 0.50, y: 0.50 },
    guide:   { prodW: 1005, prodH: 1005, top: 0, bottom: 0, left: 0, right: 0 },
    safePx:  { x: 0, y: 0, w: 1005, h: 1005 },
    rule:    "Full-canvas — no safe-area inset",
    negSpace:"None",
  },
  {
    // production 1005×360, safe (558,25)→(980,335), w=422 h=310
    // 가로형 스트립 — safe area 가 우측에 있음 (텍스트/CTA 우측, 비주얼 좌측).
    id: "StripBannerMobile",
    name: "띠배너 (Mobile)",
    w: 335, h: 120,
    allowed: { x: 0.55522, y: 0.06944, w: 0.41990, h: 0.86111 },
    anchor:  { x: 0.50, y: 0.50 },
    guide:   { prodW: 1005, prodH: 360, top: 25, bottom: 25, left: 558, right: 25 },
    safePx:  { x: 558, y: 25, w: 422, h: 310 },
    placementMode: "object-overlay",
    rule:    "Safe area 422×310 on the RIGHT (left 558 / top 25 / bottom 25 / right 25 inset)",
    negSpace:"Left 558px + 25px top/bottom/right gutters reserved for outpaint",
  },
  {
    // production 1280×100, safe (659,0)→(899,100), w=240 h=100
    // 와이드 가로형 — safe area 중앙. 좌측 659 / 우측 381 outpaint.
    id: "StripBannerPC",
    name: "띠배너 (PC)",
    w: 1280, h: 100,
    allowed: { x: 0.51484, y: 0, w: 0.18750, h: 1.0 },
    anchor:  { x: 0.50, y: 0.50 },
    guide:   { prodW: 1280, prodH: 100, top: 0, bottom: 0, left: 659, right: 381 },
    safePx:  { x: 659, y: 0, w: 240, h: 100 },
    placementMode: "object-overlay",
    rule:    "Safe area 240×100 in the center-right (left 659 / right 381 inset)",
    negSpace:"Left 659px + right 381px reserved for outpaint",
  },
];

/* ------------------------------------------------------------
   Gemini (Nano Banana) API integration via Cloudflare Worker proxy.
   The proxy:
     1) accepts the source as a base64 data URL
     2) calls Google's gemini-2.5-flash-image-preview endpoint
     3) returns the generated image as a data URL or hosted URL
   ------------------------------------------------------------ */
async function callGeminiOutpaintAPI(sourceDataUrl, spec, shotSpec, apiConfig, sourceImg = null, extraOpts = {}) {
  const { endpoint } = apiConfig;
  if (!endpoint) throw new Error("Proxy endpoint not configured");

  // ----------------------------------------------------------
  // REFINE MODE: if `inputDataUrl` is provided, skip placement and use the
  // previous banner result as the Gemini input directly. Spec/prompt
  // changes iterate on the existing output instead of re-running placement
  // from source. Cheaper visually (no re-placement), keeps composition.
  // ----------------------------------------------------------
  const refineDataUrl = extraOpts.inputDataUrl;

  /* ----------------------------------------------------------
     SAFE-AREA OUTPAINTING PIPELINE
     ----------------------------------------------------------
     1) 클라이언트에서 target 사이즈 캔버스를 미리 만들고,
        safe area 의 기하학적 중심에 상품을 정확히 배치한다.
        (상품은 safe area 안에 fit, 확대 없음, 잘리지 않음.)
     2) 캔버스의 나머지 (= red zone + 상품 주위 여백) 는 투명으로 둔다.
     3) 이 합성 캔버스를 PNG dataUrl 로 만들어 Gemini 에 전송.
        Gemini 는 투명 영역만 outpaint 하고, 상품 픽셀은 건드리지 않는다.

     이 방식의 장점:
       - 상품 위치/스케일이 100% 결정론적 (사용자 명세 지킴)
       - red zone 침범이 구조적으로 불가능 (상품이 거기에 없음)
       - Gemini 는 "주변 배경 자연스럽게 채우기"만 하면 됨 → 작업 단순화
  ---------------------------------------------------------- */
  // shotSpec / directive 추출
  const shot = (shotSpec && shotSpec.shots) ? shotSpec.shots.find(s => s.banner === spec.id) : null;
  const mood       = shotSpec?.shared?.mood || "Clean editorial product hero";
  const lighting   = shotSpec?.shared?.source_lighting_note || "Preserve source lighting exactly";
  const treatment  = shot?.background_treatment || "Extend backdrop outward, preserve hue.";
  const sharedDirective = (shotSpec?.shared?.user_directive || "").trim();
  const userDirective   = (shot?.user_directive || "").trim();

  // Pre-place product into safe area (returns canvas + meta)
  // productMargin: safe-area 안에서 product 가 차지하는 비율 (0.70~0.95 권장)
  // fitMode: "cover" (기본, safe area 가득 채움) 또는 "contain" (전체 fit)
  const productMargin = extraOpts.productMargin != null ? extraOpts.productMargin : 1.0;
  const fitMode = extraOpts.fitMode || "contain";
  let preplaced;
  let prepDataUrl;
  let placement;
  if (refineDataUrl) {
    // Refine mode: previous result is the input. Placement metadata comes from
    // banner spec geometry only (no pre-placement step).
    prepDataUrl = refineDataUrl;
    const W = spec.w * RENDER_SCALE;
    const H = spec.h * RENDER_SCALE;
    placement = {
      x: spec.allowed.x * W,
      y: spec.allowed.y * H,
      w: spec.allowed.w * W,
      h: spec.allowed.h * H,
    };
    preplaced = { dataUrl: prepDataUrl, placement, safe: {
      x1: placement.x, y1: placement.y,
      x2: placement.x + placement.w, y2: placement.y + placement.h,
    }};
  } else if (sourceImg) {
    // AI 입력은 mode="clean-bg" — 상품을 제거하고 주변 배경으로 inpaint 한
    // 깨끗한 reference. AI 가 상품 silhouette 을 보지 못하므로 잔상/중복
    // 패치가 결과에 새겨지지 않는다.
    preplaced = placeProductInSafeArea(sourceImg, spec, {
      margin: productMargin, fitMode, mode: "clean-bg",
    });
    prepDataUrl = preplaced.dataUrl;
    placement = preplaced.placement;
  } else {
    throw new Error("placeProductInSafeArea requires sourceImg");
  }
  const W = spec.w * RENDER_SCALE;
  const H = spec.h * RENDER_SCALE;
  const safe = preplaced.safe;

  // Compute the transparent (mask) regions explicitly so we can tell the AI
  // exactly which pixels to fill — and ONLY those.
  const maskBands = [];
  if (placement.y > 0) {
    maskBands.push(`TOP band: y 0 → ${Math.round(placement.y)}px (full width, ${W}×${Math.round(placement.y)}px)`);
  }
  if (placement.y + placement.h < H) {
    maskBands.push(`BOTTOM band: y ${Math.round(placement.y + placement.h)} → ${H}px (full width, ${W}×${Math.round(H - placement.y - placement.h)}px)`);
  }
  if (placement.x > 0) {
    maskBands.push(`LEFT band: x 0 → ${Math.round(placement.x)}px`);
  }
  if (placement.x + placement.w < W) {
    maskBands.push(`RIGHT band: x ${Math.round(placement.x + placement.w)} → ${W}px`);
  }

  const isObjectOverlay = spec.placementMode === "object-overlay";

  const prompt = [
    ...(userDirective || sharedDirective ? [
      `# HIGH-PRIORITY USER DIRECTIVE`,
      `User instruction(s) for this banner. They override stylistic hints but DO NOT override the anti-duplication rule below.`,
      ...(sharedDirective ? [`SHARED:`, sharedDirective] : []),
      ...(userDirective   ? [`THIS BANNER (${spec.name}, ${spec.w}×${spec.h}):`, userDirective] : []),
      ``,
    ] : []),

    ...(isObjectOverlay ? [
      `# BANNER MODE — OBJECT-OVERLAY (CRITICAL for this banner)`,
      `This is an extreme-aspect strip banner (${spec.w}×${spec.h}, ratio ${(spec.w/spec.h).toFixed(2)}:1). The product must be treated as an INDEPENDENT OBJECT placed on top of an extended background — NOT composed into the background, NOT used as a background texture.`,
      ``,
      `MANDATORY behavior:`,
      `  1) Background fills the ENTIRE ${W}×${H} canvas, including any transparent margin around the subject silhouette. Generate a calm, photo-real background scene that extends edge to edge.`,
      `  2) The subject (non-transparent pixels in the input) sits as a clearly-defined object on top of the background. It is overlaid, NOT blended.`,
      `  3) The subject KEEPS its original front-facing orientation, original aspect ratio, original logo/text/packaging orientation. NEVER tilt, lay down, rotate, perspective-shift, or skew the subject.`,
      `  4) If the input contains many subject items and they don't all fit readably, RENDER ONLY 1–2 representative hero items at a readable size. Softly remove or de-emphasize the rest into the background. Do NOT cram every item in.`,
      `  5) Never stretch the subject horizontally to fill the wide banner. Never make the subject act as the banner background.`,
      `  6) The background MUST cover 100% of the canvas everywhere the subject silhouette is not. There is NO reserved rectangular pad around the subject — fill every transparent pixel.`,
      ``,
      `STRICT BANS for this banner:`,
      `  ❌ Force-cropping the original source image to the banner's extreme aspect ratio (no 1280×100 forced crop of the source)`,
      `  ❌ Perspective transforms on the product (no tilt-back, no foreshortening, no laying down)`,
      `  ❌ Rotating or laying the product on its side`,
      `  ❌ Treating the product as a background texture, pattern, or repeating motif`,
      `  ❌ Cramming all products in when only 1–2 fit readably`,
      `  ❌ Distorting product aspect ratio to match the banner aspect ratio`,
      ``,
    ] : []),

    `# YOUR ONLY JOB — BACKGROUND EXTENSION`,
    `Look at the input image. The subject has an IRREGULAR silhouette (only non-transparent pixels). EVERY transparent (alpha=0) pixel — INCLUDING the empty margin DIRECTLY AROUND the subject's silhouette, not just at the canvas edges — must be filled with seamless background continuation. There is NO bounding box, NO rectangular pad, NO safe area to leave alone. The boundary is the subject's pixel-perfect silhouette.`,
    ``,
    `You will OUTPUT a ${W}×${H}px fully-opaque image where:`,
    `  - Every non-transparent input pixel is copied through EXACTLY (no color change, no edge change, no logo change).`,
    `  - Every transparent input pixel is filled with photo-real background that continues the scene seamlessly.`,
    `Think of yourself as a Photoshop "Content-Aware Fill" with a per-pixel mask — you fill empty space with continuation of the surrounding pixels, even when the empty space sits right next to the subject. You are NOT generating products. You are extending background pixel by pixel.`,
    ``,

    `# CRITICAL — ABSOLUTELY FORBIDDEN`,
    `❌ DO NOT draw a second copy of the main subject anywhere in the output.`,
    `❌ DO NOT draw any additional item, container, label, packaging, person, or duplicate element in the mask area.`,
    `❌ DO NOT mirror or repeat the main subject above or below itself.`,
    `❌ DO NOT add new objects, decorative props, or additional items.`,
    `❌ DO NOT add text, logos, watermarks, captions, prices, badges, or graphic elements.`,
    `❌ DO NOT modify the main subject's pixels in any way (color, edge, silhouette, shadow).`,
    `❌ DO NOT leave any pixel transparent, black, gray, or solid empty in the output.`,
    ``,
    `# ADAPTIVE STRATEGY (choose dynamically based on image type + target ratio)`,
    `Image type classification:`,
    `  A-TYPE (scene-heavy): lifestyle, food, environmental, interior, background-rich. Strategy → scene continuation, generative outpainting, environmental expansion.`,
    `  B-TYPE (product-heavy): minimal background, isolated products, object-centric. Strategy → product extraction, adaptive recomposition, smart layout.`,
    ``,
    `Strategy selection:`,
    `  Square / Wide ratio + scene-heavy: preserve scene, outpaint outward.`,
    `  Narrow / Vertical ratio: product-centric recomposition. Extract the main product cluster, allow partial exclusion, fewer products is OK. Result must feel like an art-directed vertical commerce banner, NOT a resized square image.`,
    `  Extreme aspect ratio: smart crop + adaptive recomposition over forced outpainting.`,
    ``,
    `# QUALITY VALIDATION (REJECT if any of these occur)`,
    `❌ Products duplicated or cloned`,
    `❌ Product proportions distorted`,
    `❌ Products too small in large empty space`,
    `❌ Backgrounds stretched or smeared`,
    `❌ Scene visually disconnected (top vs bottom feels like separate scenes)`,
    `❌ Empty black regions or transparent areas in output`,
    `❌ Blurry filler backgrounds`,
    `❌ Composition loses visual balance`,
    ``,
    `# VERTICAL PRODUCT BANNER STRATEGY (for narrow/tall ratios)`,
    `- Do NOT force-preserve the entire original composition when the target ratio is narrow/tall.`,
    `- Selectively extract the most visually important products or product groups.`,
    `- Recompose layout SPECIFICALLY for the target vertical ratio.`,
    `- Use product-centric composition (not scene-centric) when space is tight.`,
    `- Fewer products in narrow layouts is OK. Maintain strong visual volume around the subject — avoid excessive empty rectangular bands around small products.`,
    `- Allow controlled cropping and partial object exclusion.`,
    `- NEVER distort product proportions. NEVER duplicate or fake products. NEVER add abstract filler backgrounds.`,
    `- Generated background areas: simple, minimal, visually supportive. Preserve original lighting / shadow direction / perspective / style.`,
    `- Keep composition clean, premium, centered.`,
    `- Adaptive: square/wide → scene preservation; narrow vertical → selective recomposition.`,
    ``,
    `# GENERATION QUALITY RULES (NO PIXEL STRETCHING)`,
    `- DO NOT stretch, smear, blur, mirror, or propagate edge pixels to fill empty space.`,
    `- DO NOT use texture stretching, linear background extension, vertical streaks, blurred bands, or stretched gradients.`,
    `- DO NOT solve this by repeating image edges, content-aware smear, or duplicated textures.`,
    `- DO reconstruct missing environmental space REALISTICALLY using scene-aware generation (true diffusion-based outpainting).`,
    `- DO generate physically believable environments — each new pixel should look like a newly photographed area of the same scene, with real spatial structure, depth, perspective, and lighting.`,
    `- Every generated region must contain REALISTIC environmental detail and spatial continuity.`,
    `- This is segment → protect subject → understand scene depth → generate missing background as realistic continuation. NOT resize, edge stretch, blur fill, or smear.`,
    ``,
    `# ASPECT RATIO STRATEGY (auto-adapt)`,
    `- Wide / Square output: outpainting-centric — preserve full original framing, extend background outward.`,
    `- Narrow vertical output: light cropping + outpainting hybrid — controlled cropping is allowed when needed to maintain realism. Prioritize scene continuity over preserving every pixel of the original framing.`,
    `- For extremely narrow/tall aspect ratios: prefer realistic partial framing over repeated objects or distorted layouts. The output should feel like a naturally reframed photograph, NOT an AI-generated collage.`,
    `- Never duplicate or artificially shrink the main subject to fit the layout. Preserve natural scale and perspective.`,
    ``,
    `# VERTICAL LAYOUT CONTINUITY (for tall banners)`,
    `- Preserve continuous environmental depth in vertical layouts.`,
    `- Extend backgrounds as ONE connected physical space, not a collage of panels.`,
    `- Continue surfaces, windows, floors, fabrics, shadows, and perspective naturally across the ENTIRE height.`,
    `- Avoid disconnected top and bottom sections. No abstract filler panels or blurred separators.`,
    `- Maintain a single coherent scene from top to bottom.`,
    ``,
    `# SCENE CONTINUITY RULES (REAL ENVIRONMENT EXTENSION)`,
    `- Preserve original environmental structure and scene continuity.`,
    `- Extend the existing scene NATURALLY — do not generate abstract filler backgrounds.`,
    `- Continue real environmental elements: tables, walls, windows, fabrics, surfaces, shadows, plants, lighting, depth — consistently.`,
    `- NEVER use empty gradient fills, blurry placeholder textures, abstract streaks, or artificial background panels.`,
    `- The generated area must feel physically connected to the original image space.`,
    `- Maintain consistent perspective, camera angle, depth, and lighting direction across the entire composition.`,
    `- Do not split the image into visually disconnected sections. Top and bottom regions must look like ONE scene, not separate scenes.`,
    `- Treat this as a SINGLE coherent scene, not a banner template. Real environmental continuation always — abstract filler never.`,
    ``,
    `# COMPOSITION PRESERVATION RULES (TOP PRIORITY)`,
    `- The subject's silhouette, scale, position, and composition are FINAL — do not re-center, re-zoom, re-balance, or redesign the layout.`,
    `- Keep the original visual hierarchy intact. The subject in the input image was placed deliberately.`,
    `- Preserve the EXACT number of original subjects. Never clone, mirror, repeat, duplicate, or recreate the main subject.`,
    `- Never generate additional products, people, objects, or items beyond what is in the original.`,
    `- Treat the output as the original image with extra canvas space added, NOT as a redesigned banner.`,
    `- Only transparent (alpha=0) areas need filling — but ALL of them, including transparent pixels touching the subject's silhouette. There is no rectangular pad around the subject that should be left untouched.`,
    ``,
    `# BACKGROUND EXTENSION RULES`,
    `- Extend the existing environment naturally from nearby context.`,
    `- Continue adjacent surfaces, textures, shadows, gradients, lighting, perspective, atmosphere, and depth seamlessly.`,
    `- Fully complete ALL generated canvas areas — no empty, black, flat, unfinished, or low-detail regions allowed.`,
    `- No abrupt generation cutoffs or partially rendered backgrounds — fill ENTIRE canvas edge to edge.`,
    `- Use soft feathered blending between original and generated regions. No visible seams or hard mask boundaries.`,
    `- For tall/wide aspect ratios with large mask areas, generate consistent texture all the way to the canvas edges. Do not stop partway.`,
    ``,
    `# BLENDING REQUIREMENT (CRITICAL FOR QUALITY)`,
    `"Seamlessly outpaint and extend the surrounding background environment scenery to the top and bottom white masked areas based on the center product image. Perfectly match the product's original lighting, color gradients, shadows, and textures. Do not generate any blurred box lines, vertical distortion stretching, or grid-like image tiling artifacts."`,
    `Universal rule — works generically across beauty, fashion, food, lifestyle, product shots, model photography, and promotional banners. Do not assume a specific background type; analyze the actual pixels at the main subject's edges and continue that exact look outward.`,
    `IMPORTANT: The input image has TRANSPARENT pixels (alpha = 0) in the mask area. These are NOT black — they are empty pixels you must fill. Treat alpha=0 pixels as the inpainting target region. Output must be fully opaque (no transparency in final image, no black bands at top or bottom).`,
    ``,

    `# WHAT TO PUT IN THE MASK AREA`,
    `The mask area = every transparent (alpha=0) pixel on the input canvas. That includes both:`,
    `  (a) The outer bands around the subject's bounding rectangle:`,
    ...maskBands.map(b => `        • ${b}`),
    `  (b) Every transparent pixel INSIDE the subject's bounding rectangle that is not part of the subject silhouette itself (corners of the rectangle, gaps between objects, margin around the silhouette).`,
    `All of these must be filled with PURE BACKGROUND ONLY. Take the background visible at the subject's adjacent edge and CONTINUE that exact background outward AND inward, all the way up to the subject's pixel-perfect silhouette.`,
    ``,
    `Background interpretation by category (auto-adapt):`,
    `  • Solid color or gradient → continue smoothly. Same hue, same luminance gradient.`,
    `  • Studio sweep (paper / fabric backdrop) → continue the same material with matching falloff.`,
    `  • Scene / lifestyle (room, kitchen, outdoor, table-top) → continue the floor, wall, surface, depth-of-field as if the camera framed wider.`,
    `Match lighting direction, color temperature, depth-of-field exactly. NO new light sources.`,
    ``,

    `# SUBJECT — PIXEL-LEVEL PRESERVATION (NOT RECTANGULAR)`,
    `The subject's bounding rectangle is approximately (${Math.round(placement.x)}, ${Math.round(placement.y)}) → (${Math.round(placement.x + placement.w)}, ${Math.round(placement.y + placement.h)}), BUT this is informational only — the subject does NOT fill this rectangle.`,
    `Inside this rectangle there are TWO kinds of pixels:`,
    `  • Non-transparent (alpha > 0): the actual subject silhouette. Copy these pixels EXACTLY — do not change color, edge, shadow, logo, text, or proportion.`,
    `  • Transparent (alpha = 0): empty space sitting next to the subject (corners of the bounding box, gaps between products, margin around the silhouette). These MUST be filled with seamless background just like any other transparent pixel on the canvas.`,
    `Do NOT preserve the rectangle as a unit. Preserve only the subject's actual pixels. The background must extend all the way up to the subject's silhouette edge, never leaving a visible rectangular pad or frame.`,
    `If the subject's edge appears cropped at the banner edge, ACCEPT THE CROP. Do NOT try to complete the missing portion.`,
    ``,

    `# OUTPUT REQUIREMENT — STRICT`,
    `- Output must be exactly ${W}×${H}px, fully opaque, photo-real.`,
    `- Every pixel filled. NO transparent areas. NO solid color blocks.`,
    `- One continuous photograph — subject in its original position, background extending naturally to all edges AND right up to the subject's silhouette (no rectangular pad).`,
    `- If you cannot decide what to draw in the mask area, default to a smooth continuation of the dominant color at the adjacent subject edge. NEVER default to drawing the subject again.`,
    ``,
    `# RED-ZONE NOTE`,
    `The top/bottom bands you fill will later be overlaid with headline/CTA text by the app. Keep these areas visually CALM — smooth surface, soft falloff. No busy patterns, no extra props, no text or logos drawn in. (Calm ≠ uniform color. A smooth gradient or soft floor is fine.)`,
    `Lighting note: ${lighting}`,
    ``,
    `# STYLE HINTS (optional, only if they don't conflict with above rules)`,
    `- Mood: ${mood}`,
    `- Background treatment hint: ${treatment}`,
    `- Photo-real, editorial banner quality.`,
  ].join("\n");

  const res = await fetch(endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      dataUrl: prepDataUrl,
      prompt,
      width:  W,
      height: H,
    }),
  });
  if (!res.ok) {
    let detail = "";
    try { detail = await res.text(); } catch {}
    throw new Error(`Proxy ${res.status}: ${detail.slice(0, 200)}`);
  }
  const json = await res.json();
  if (!json.url) throw new Error("Proxy did not return an image URL");

  // url can be either a https:// URL or a base64 data: URL — fetch() handles both
  const imgRes = await fetch(json.url);
  if (!imgRes.ok) throw new Error(`Failed to fetch result image: ${imgRes.status}`);
  return await imgRes.blob();
}

/* ------------------------------------------------------------
   extractProductLayer — 첨부 이미지에서 "상품 오브젝트만" 분리
   ------------------------------------------------------------
   safeArea 안에 들어가는 것은 첫 번째 첨부 이미지의 사각형이 아니라,
   그 안에 있는 상품 그 자체. 이 함수는 source 이미지의 배경을 alpha 로
   날려서 product 만 남긴 canvas 를 반환한다.

   동작:
     1) source 가 이미 transparent PNG 면 그대로 사용 (이미 분리되어 있음).
     2) 아니면 코너/가장자리 픽셀에서 배경색을 추정하고, 그 색과 충분히
        가까운 픽셀의 alpha 를 0 으로 만든다.
     3) 결과는 source 와 같은 해상도의 canvas. 상품 silhouette 외 픽셀은
        alpha=0 (완전 투명).

   반환: { canvas, hadAlpha, bgColor:{r,g,b}, threshold }
   ------------------------------------------------------------ */
/* ------------------------------------------------------------
   computeDistanceTransform — 2-pass chamfer 3-4 distance transform
   ------------------------------------------------------------
   bgMask: Uint8Array(W*H), 1 = background, 0 = product
   반환: Int32Array(W*H), 각 픽셀에서 가장 가까운 product 픽셀까지의 거리
        chamfer 단위 (실제 픽셀 거리 × 3). product 픽셀 자체는 0.
   ------------------------------------------------------------ */
function computeDistanceTransform(bgMask, W, H) {
  const dist = new Int32Array(W * H);
  const INF = 0x3fffffff;
  for (let i = 0; i < W * H; i++) dist[i] = bgMask[i] ? INF : 0;
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const i = y * W + x;
      let v = dist[i];
      if (x > 0)              v = Math.min(v, dist[i - 1]     + 3);
      if (y > 0)              v = Math.min(v, dist[i - W]     + 3);
      if (x > 0 && y > 0)     v = Math.min(v, dist[i - W - 1] + 4);
      if (x < W - 1 && y > 0) v = Math.min(v, dist[i - W + 1] + 4);
      dist[i] = v;
    }
  }
  for (let y = H - 1; y >= 0; y--) {
    for (let x = W - 1; x >= 0; x--) {
      const i = y * W + x;
      let v = dist[i];
      if (x < W - 1)              v = Math.min(v, dist[i + 1]     + 3);
      if (y < H - 1)              v = Math.min(v, dist[i + W]     + 3);
      if (x < W - 1 && y < H - 1) v = Math.min(v, dist[i + W + 1] + 4);
      if (x > 0     && y < H - 1) v = Math.min(v, dist[i + W - 1] + 4);
      dist[i] = v;
    }
  }
  return dist;
}

/* ------------------------------------------------------------
   inpaintCleanBackground — 상품 영역을 주변 배경으로 메워 clean bg 생성
   ------------------------------------------------------------
   "상품이 제거된 깨끗한 배경" 생성을 위한 fast iterative inpainting.

   동작:
     1) 원본 + product layer 를 small (~256px) 로 downsample
     2) Product 픽셀을 unknown 으로 표시, 주변 known 픽셀의 평균으로 점진 확산
        (8-connected, 한 패스당 boundary 1px 씩 안쪽으로)
     3) 부드러운 transition 을 위해 미세 blur 적용
     4) 원본 크기로 upsample 한 fill 을 product 영역에만 합성
        (known 영역은 원본 detail 유지)

   결과: 상품이 있던 자리가 주변 배경으로 자연스럽게 채워진 canvas.
   AI 는 이 clean bg 를 reference 로 받아 상품 형태/잔상 없이 outpaint.
   ------------------------------------------------------------ */
function inpaintCleanBackground(sourceImg, productLayerCanvas, opts = {}) {
  const W = sourceImg.naturalWidth || sourceImg.width;
  const H = sourceImg.naturalHeight || sourceImg.height;
  const longSide = Math.max(W, H);
  const targetLong = opts.targetLong || 256;
  const sf = Math.min(1, targetLong / longSide);
  const sw = Math.max(8, Math.round(W * sf));
  const sh = Math.max(8, Math.round(H * sf));

  // Source at small size
  const small = document.createElement("canvas");
  small.width = sw; small.height = sh;
  const sctx = small.getContext("2d");
  sctx.imageSmoothingEnabled = true;
  sctx.imageSmoothingQuality = "high";
  sctx.drawImage(sourceImg, 0, 0, sw, sh);

  // Product mask at small size — alpha 채널을 mask 로 사용
  const maskCanvas = document.createElement("canvas");
  maskCanvas.width = sw; maskCanvas.height = sh;
  const mctx = maskCanvas.getContext("2d");
  mctx.imageSmoothingEnabled = true;
  mctx.imageSmoothingQuality = "high";
  mctx.drawImage(productLayerCanvas, 0, 0, sw, sh);
  const maskData = mctx.getImageData(0, 0, sw, sh).data;

  const imgData = sctx.getImageData(0, 0, sw, sh);
  const d = imgData.data;
  const known = new Uint8Array(sw * sh);
  let unknownCount = 0;
  for (let i = 0; i < sw * sh; i++) {
    if (maskData[i * 4 + 3] > 32) {
      // Product pixel → unknown, zero out for clean blending
      d[i * 4]     = 0;
      d[i * 4 + 1] = 0;
      d[i * 4 + 2] = 0;
      d[i * 4 + 3] = 255;
      unknownCount++;
    } else {
      known[i] = 1;
    }
  }

  // Iterative boundary fill: 한 패스당 경계 1px 씩 내부로 확장
  const maxIter = sw + sh;
  for (let iter = 0; iter < maxIter && unknownCount > 0; iter++) {
    const toFill = [];
    for (let y = 0; y < sh; y++) {
      for (let x = 0; x < sw; x++) {
        const i = y * sw + x;
        if (known[i]) continue;
        let r = 0, g = 0, b = 0, n = 0;
        for (let dy = -1; dy <= 1; dy++) {
          for (let dx = -1; dx <= 1; dx++) {
            if (dx === 0 && dy === 0) continue;
            const xx = x + dx, yy = y + dy;
            if (xx < 0 || yy < 0 || xx >= sw || yy >= sh) continue;
            const j = yy * sw + xx;
            if (!known[j]) continue;
            const p = j * 4;
            r += d[p]; g += d[p + 1]; b += d[p + 2]; n++;
          }
        }
        if (n > 0) toFill.push(i, r / n, g / n, b / n);
      }
    }
    if (toFill.length === 0) break;
    for (let k = 0; k < toFill.length; k += 4) {
      const i = toFill[k];
      const p = i * 4;
      d[p]     = Math.round(toFill[k + 1]);
      d[p + 1] = Math.round(toFill[k + 2]);
      d[p + 2] = Math.round(toFill[k + 3]);
      known[i] = 1;
      unknownCount--;
    }
  }
  sctx.putImageData(imgData, 0, 0);

  // Blur 로 fill 영역 부드럽게
  const blurred = document.createElement("canvas");
  blurred.width = sw; blurred.height = sh;
  const bctx = blurred.getContext("2d");
  bctx.filter = "blur(2px)";
  bctx.drawImage(small, 0, 0);
  bctx.filter = "none";

  // Output: 원본 위에, blurred upsample 을 product 영역에만 overlay
  const out = document.createElement("canvas");
  out.width = W; out.height = H;
  const octx = out.getContext("2d");
  octx.imageSmoothingEnabled = true;
  octx.imageSmoothingQuality = "high";
  octx.drawImage(sourceImg, 0, 0);

  // Fill canvas: upsampled blur, masked to product region only
  const fill = document.createElement("canvas");
  fill.width = W; fill.height = H;
  const fctx = fill.getContext("2d");
  fctx.imageSmoothingEnabled = true;
  fctx.imageSmoothingQuality = "high";
  fctx.drawImage(blurred, 0, 0, W, H);
  fctx.globalCompositeOperation = "destination-in";
  fctx.drawImage(productLayerCanvas, 0, 0);

  octx.drawImage(fill, 0, 0);
  return out;
}

/* ------------------------------------------------------------
   extractTightProductLayer — overlay 용 tight silhouette (작은 feather)
   ------------------------------------------------------------
   AI 결과 위에 최종 합성할 때 사용하는 product layer. extractProductLayer
   의 wide soft-island 와 달리 silhouette 만 보존하고 edge 에 작은 feather
   (2-4px) 만 적용 — 주변 sky 픽셀이 끌려 들어가지 않아 AI 배경과 깨끗하게
   섞임.
   ------------------------------------------------------------ */
function extractTightProductLayer(sourceImg, opts = {}) {
  return extractProductLayer(sourceImg, {
    marginPx:  opts.marginPx  != null ? opts.marginPx  : 0,
    featherPx: opts.featherPx != null ? opts.featherPx : 3,
    threshold: opts.threshold,
  });
}

/* ------------------------------------------------------------
   extractProductLayer — 첨부 이미지에서 "상품 + 주변 soft island" 분리
   ------------------------------------------------------------
   ⚠️ 이전 버전(단순 chroma-key threshold) 의 결함:
     • 상품과 배경 색이 비슷한 경우 상품 edge / 로고 / 텍스트 손실
     • 그림자/라이팅 손실로 AI 가 배경을 자연스럽게 잇지 못함
     • 가장자리 hard-cut → halo 발생
     • 상품 하단(바닥 그림자) 잘림

   ✅ 새 방식 (soft preserve island):
     1) 4개 코너에서만 배경색 샘플링 (가장 안정적인 background 표본)
     2) Flood-fill: 코너로부터 chroma-유사 픽셀만 background 로 표시
        — 연결되지 않은 "같은 색" 픽셀(예: 상품 내부 텍스트와 배경이 같은 톤)
          은 보호됨
     3) Distance transform: 각 bg 픽셀이 product 픽셀까지의 거리 계산
     4) Alpha 결정:
        • dist == 0          → 상품 본체, alpha=255
        • dist <= margin     → 상품 주변 soft island (그림자/라이팅 포함), alpha=255
        • dist <= margin+ft  → feather 밴드, alpha 점진 감소
        • else               → 완전 배경, alpha=0 (AI 가 outpaint)

   margin/feather 는 이미지 크기 비례 (각 약 3% / 4%). AI 는 island 의 자연스러운
   톤을 그대로 이어받아 배경을 확장하므로 seam 이 거의 보이지 않음.
   ------------------------------------------------------------
   반환: { canvas, hadAlpha, bgColor:{r,g,b}, threshold, marginPx, featherPx }
   ------------------------------------------------------------ */
function extractProductLayer(sourceImg, opts = {}) {
  const W = sourceImg.naturalWidth || sourceImg.width;
  const H = sourceImg.naturalHeight || sourceImg.height;
  const c = document.createElement("canvas");
  c.width = W; c.height = H;
  const ctx = c.getContext("2d");
  ctx.drawImage(sourceImg, 0, 0);

  const img = ctx.getImageData(0, 0, W, H);
  const d = img.data;

  // ---- Mark existing alpha=0 pixels as background up front ----
  // 입력이 이미 부분적으로 투명한 경우 그 픽셀들은 처음부터 bg 로 간주하고,
  // 나머지 opaque 영역에 대해서만 chroma-key 분석을 수행한다.
  // 코너가 투명이면 그 다음 opaque 픽셀을 찾아서 bg 표본으로 사용.
  const initialBg = new Uint8Array(W * H);
  for (let i = 0; i < W * H; i++) {
    if (d[i * 4 + 3] < 8) initialBg[i] = 1;
  }

  // ---- Find first opaque pixel scanning inward from each corner ----
  // 코너가 투명이어도 그 안쪽 opaque 가장자리를 bg 표본으로 사용 (보통 진짜 배경).
  function scanFromCorner(startX, startY, dx, dy, maxStep) {
    for (let s = 0; s < maxStep; s++) {
      const x = startX + dx * s;
      const y = startY + dy * s;
      if (x < 0 || y < 0 || x >= W || y >= H) break;
      const i = y * W + x;
      if (!initialBg[i]) return { x, y };
    }
    return null;
  }
  const maxStep = Math.min(W, H) * 0.3;
  const cornerSeedsRaw = [
    scanFromCorner(0, 0, 1, 1, maxStep),
    scanFromCorner(W - 1, 0, -1, 1, maxStep),
    scanFromCorner(0, H - 1, 1, -1, maxStep),
    scanFromCorner(W - 1, H - 1, -1, -1, maxStep),
  ].filter(Boolean);

  // 코너에서 시작해 첫 opaque 픽셀이 모두 찾아지지 않았다 (이미지 전부 투명)
  // → 분석할 게 없음, alpha 그대로 반환
  if (cornerSeedsRaw.length === 0) {
    return {
      canvas: c, hadAlpha: true, bgColor: null,
      threshold: 0, marginPx: 0, featherPx: 0,
    };
  }

  // ---- Sample background color around each seed (small patch) ----
  const PATCH = Math.max(4, Math.round(Math.min(W, H) * 0.015));
  const cornerSamples = cornerSeedsRaw.map(({ x, y }) => {
    // 시드 주변 PATCH×PATCH 영역에서 opaque 픽셀만 평균
    const x0 = Math.max(0, x - Math.floor(PATCH / 2));
    const y0 = Math.max(0, y - Math.floor(PATCH / 2));
    const x1 = Math.min(W, x0 + PATCH);
    const y1 = Math.min(H, y0 + PATCH);
    let r = 0, g = 0, b = 0, n = 0;
    for (let py = y0; py < y1; py++) {
      for (let px = x0; px < x1; px++) {
        const i = py * W + px;
        if (initialBg[i]) continue;
        const p = i * 4;
        r += d[p]; g += d[p + 1]; b += d[p + 2]; n++;
      }
    }
    if (n === 0) return null;
    return { r: r / n, g: g / n, b: b / n };
  }).filter(Boolean);

  if (cornerSamples.length === 0) {
    return {
      canvas: c, hadAlpha: true, bgColor: null,
      threshold: 0, marginPx: 0, featherPx: 0,
    };
  }

  // Median-ish: 가장 가까운 표본들의 평균. outlier 1개 제외.
  const pickRobustBg = (samples) => {
    if (samples.length === 1) {
      return { r: Math.round(samples[0].r), g: Math.round(samples[0].g), b: Math.round(samples[0].b) };
    }
    if (samples.length === 2) {
      return {
        r: Math.round((samples[0].r + samples[1].r) / 2),
        g: Math.round((samples[0].g + samples[1].g) / 2),
        b: Math.round((samples[0].b + samples[1].b) / 2),
      };
    }
    const pairs = [];
    for (let i = 0; i < samples.length; i++) {
      for (let j = i + 1; j < samples.length; j++) {
        const dr = samples[i].r - samples[j].r;
        const dg = samples[i].g - samples[j].g;
        const db = samples[i].b - samples[j].b;
        pairs.push({ i, j, d2: dr * dr + dg * dg + db * db });
      }
    }
    pairs.sort((a, b) => a.d2 - b.d2);
    const keep = new Set([pairs[0].i, pairs[0].j, pairs[1].i, pairs[1].j]);
    const used = [...keep].map(i => samples[i]);
    const r = used.reduce((s, c) => s + c.r, 0) / used.length;
    const g = used.reduce((s, c) => s + c.g, 0) / used.length;
    const b = used.reduce((s, c) => s + c.b, 0) / used.length;
    return { r: Math.round(r), g: Math.round(g), b: Math.round(b) };
  };
  const { r: bgR, g: bgG, b: bgB } = pickRobustBg(cornerSamples);

  // ---- Threshold (chroma similarity) ----
  let maxD2 = 0;
  for (const s of cornerSamples) {
    const dr = s.r - bgR, dg = s.g - bgG, db = s.b - bgB;
    maxD2 = Math.max(maxD2, dr * dr + dg * dg + db * db);
  }
  const cornerStd = Math.sqrt(maxD2);
  const threshold = opts.threshold != null
    ? opts.threshold
    : Math.max(40, Math.min(75, 36 + cornerStd * 1.2));
  const t2 = threshold * threshold;

  // ---- Flood-fill from seeds: connected bg-similar pixels ----
  // initialBg 의 alpha=0 픽셀을 BFS 시작점에도 자동 포함 (이미 bg 로 표시됨).
  const bgMask = new Uint8Array(initialBg); // copy
  const queue = new Int32Array(W * H);
  let qHead = 0, qTail = 0;
  const isBgColor = (i) => {
    const p = i * 4;
    if (initialBg[i]) return true; // 이미 투명한 픽셀
    const dr = d[p] - bgR, dg = d[p + 1] - bgG, db = d[p + 2] - bgB;
    return dr * dr + dg * dg + db * db <= t2;
  };
  // 이미 bg 로 표시된 모든 픽셀을 큐에 넣어 BFS 시작
  for (let i = 0; i < W * H; i++) {
    if (bgMask[i]) queue[qTail++] = i;
  }
  // 추가로 코너 시드도 직접 넣기 (initialBg 안 잡힌 경우 대비)
  for (const { x, y } of cornerSeedsRaw) {
    const i = y * W + x;
    if (!bgMask[i] && isBgColor(i)) { bgMask[i] = 1; queue[qTail++] = i; }
  }
  while (qHead < qTail) {
    const i = queue[qHead++];
    const x = i % W, y = (i - x) / W;
    if (x > 0)     { const j = i - 1;   if (!bgMask[j] && isBgColor(j)) { bgMask[j] = 1; queue[qTail++] = j; } }
    if (x < W - 1) { const j = i + 1;   if (!bgMask[j] && isBgColor(j)) { bgMask[j] = 1; queue[qTail++] = j; } }
    if (y > 0)     { const j = i - W;   if (!bgMask[j] && isBgColor(j)) { bgMask[j] = 1; queue[qTail++] = j; } }
    if (y < H - 1) { const j = i + W;   if (!bgMask[j] && isBgColor(j)) { bgMask[j] = 1; queue[qTail++] = j; } }
  }

  // ---- Distance transform: dist from nearest product (non-bg) pixel ----
  const dist = computeDistanceTransform(bgMask, W, H);

  // ---- Compute soft alpha from distance ----
  // margin = 상품 주변 보호 영역 (alpha=255, 그림자/라이팅 포함)
  // feather = margin 바깥의 부드러운 falloff 밴드
  const longSide = Math.max(W, H);
  const marginPx  = opts.marginPx  != null ? opts.marginPx  : Math.round(longSide * 0.030);
  const featherPx = opts.featherPx != null ? opts.featherPx : Math.round(longSide * 0.040);
  const marginCh  = marginPx * 3;  // chamfer 단위 (1 pixel = 3 chamfer)
  const featherCh = featherPx * 3;

  for (let i = 0; i < W * H; i++) {
    const p = i * 4;
    const dCh = dist[i];
    if (dCh === 0) {
      // 상품 본체 — alpha 유지 (보통 255)
      continue;
    }
    if (dCh <= marginCh) {
      // soft preserve island — 그림자/라이팅/배경 일부 보존
      // 원본 alpha 유지 (이미 255)
      continue;
    }
    if (dCh <= marginCh + featherCh) {
      // feather 밴드: 점진적 alpha 감소
      const t = (dCh - marginCh) / featherCh;
      d[p + 3] = Math.round(255 * (1 - t));
    } else {
      // 완전 배경 — AI 가 outpaint
      d[p + 3] = 0;
    }
  }

  // ---- hadAlpha: 입력에 부분 투명 픽셀이 있었는지 ----
  let hadAlpha = false;
  for (let i = 0; i < W * H; i++) {
    if (initialBg[i]) { hadAlpha = true; break; }
  }

  ctx.putImageData(img, 0, 0);
  return {
    canvas: c,
    hadAlpha,
    bgColor: { r: bgR, g: bgG, b: bgB },
    threshold,
    marginPx,
    featherPx,
  };
}

/* ------------------------------------------------------------
   placeProductInSafeArea — 상품 최대화 사전 배치
   ------------------------------------------------------------
   변경 이력 (중요):
     v1: source 를 그대로 safe area 의 92% 에 fit. → 상품이 작게 보임
     v2: 1) source 에서 상품 bbox 를 먼저 검출 (배경 여백 제거)
         2) 그 bbox 를 safe area 의 98% 까지 키워서 fit
         3) 원본 source 의 해당 영역만 crop 해서 그림
     이러면 source 가 1024×1024 에 상품이 60% 만 차지해도, safe area
     를 가득 채울 때까지 키워진다. user 요구사항: "상품이 safe area 의
     상/하/좌/우 경계선에 거의 닿을 정도로 꽉 차게."

   반환: { dataUrl, safe:{x1,y1,x2,y2}, placement:{x,y,w,h}, productBbox }
   ------------------------------------------------------------ */
function placeProductInSafeArea(sourceImg, spec, opts = {}) {
  // mode: "product" (기본, soft preserve island) | "clean-bg" (상품 제거된 깨끗한 배경)
  //       | "tight-product" (작은 feather 만, 최종 overlay 용)
  const mode = opts.mode || "product";
  // ----------------------------------------------------------
  // STEP 0 — EXTRACT PRODUCT OBJECT
  //   safeArea 에 들어가는 것은 첨부 이미지의 사각형이 아니라 그 안의
  //   "상품 그 자체". 먼저 source 의 배경을 alpha 로 날려서 상품 silhouette
  //   만 남긴다. 이렇게 뽑은 layer 를 이후 모든 drawImage 의 입력으로 쓴다.
  //   결과: safeArea 안에 사각형 썸네일이 아닌 상품 오브젝트만 보임.
  // ----------------------------------------------------------
  const productLayer = extractProductLayer(sourceImg, opts.extract || {});
  const productCanvas = productLayer.canvas;

  // mode 에 따라 placement 위치에 그릴 canvas 선택:
  //  - "product"       → soft preserve island 가 포함된 product layer (기본)
  //  - "clean-bg"      → 상품 제거 후 inpaint 된 clean background (AI 입력용)
  //  - "tight-product" → silhouette + 3px feather (최종 overlay 용)
  let drawCanvas;
  if (mode === "clean-bg") {
    drawCanvas = inpaintCleanBackground(sourceImg, productCanvas);
  } else if (mode === "tight-product") {
    drawCanvas = extractTightProductLayer(sourceImg, opts.tightExtract || {}).canvas;
  } else {
    drawCanvas = productCanvas;
  }
  // ----------------------------------------------------------
  // FIT MODE — contain only (aspect-preserving, no cropping).
  //   상품 원본 aspect ratio 를 계산하고 safe area 안에 들어갈 수 있는
  //   최대 크기로 contain 배치. 남는 영역은 AI 가 자연스러운 배경으로
  //   채우거나(기본 경로), AI 비활성 시 neutral backdrop 으로 채운다.
  //   상품 픽셀은 절대 stretch / squash / crop 되지 않음.
  //
  //   ("cover" 옵션은 더 이상 노출되지 않음 — 비율 왜곡 위험으로 제거.
  //    호환을 위해 내부적으로 "cover" 가 들어와도 contain 으로 처리.)
  // ----------------------------------------------------------
  const fitMode = "contain";
  const margin = opts.margin != null ? opts.margin : 0.95;

  const W = spec.w * RENDER_SCALE;
  const H = spec.h * RENDER_SCALE;

  // Safe area in canvas pixels
  const safeX = spec.allowed.x * W;
  const safeY = spec.allowed.y * H;
  const safeW = spec.allowed.w * W;
  const safeH = spec.allowed.h * H;
  const safeCx = safeX + safeW / 2;
  const safeCy = safeY + safeH / 2;

  // ---- SPECIAL CASE: safe area = entire banner ----
  // 예: Exhibition. safe area 가 캔버스 전체면 추출된 상품 레이어를 그대로
  // contain-fit 으로 배치. AI outpaint 가 letterbox 영역을 채움.
  const isFullCanvas =
    Math.abs(spec.allowed.x) < 0.001 &&
    Math.abs(spec.allowed.y) < 0.001 &&
    Math.abs(spec.allowed.w - 1.0) < 0.001 &&
    Math.abs(spec.allowed.h - 1.0) < 0.001;
  if (isFullCanvas) {
    const c = document.createElement("canvas");
    c.width = W; c.height = H;
    const ctx = c.getContext("2d");
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = "high";
    // Contain-fit on chosen layer (기본은 배경 제거된 상품 silhouette,
    // clean-bg / tight-product 모드는 각각의 canvas 사용).
    const scale = Math.min(W / drawCanvas.width, H / drawCanvas.height);
    const drawW = drawCanvas.width  * scale;
    const drawH = drawCanvas.height * scale;
    const drawX = (W - drawW) / 2;
    const drawY = (H - drawH) / 2;
    ctx.drawImage(drawCanvas, drawX, drawY, drawW, drawH);
    return {
      dataUrl: c.toDataURL("image/png"),
      safe: { x1: 0, y1: 0, x2: W, y2: H },
      placement: { x: drawX, y: drawY, w: drawW, h: drawH },
      fitMode: "full-contain",
      productBbox: {
        detected: false, confidence: 0,
        sourceRegion: { x: 0, y: 0, w: productCanvas.width, h: productCanvas.height },
      },
      isFullCanvas: true,
      extract: productLayer,
    };
  }

  // ---- STEP 1: 추출된 product layer 의 alpha 기반 bbox 검출 ----
  // bg 가 alpha=0 이므로 detectProductBbox 는 정확한 상품 박스를 잡는다.
  let bbox;
  try { bbox = detectProductBbox(productCanvas); } catch { bbox = null; }
  const detected = bbox && bbox.confidence >= 0.05;
  // contain 전용: bbox 주위에 살짝만 padding. (cover 가 사라져서 crop 위험 없음)
  const padRatio = 0.03;
  let cx0, cy0, cw, ch;
  if (detected) {
    const padX = bbox.w * padRatio;
    const padY = bbox.h * padRatio;
    cx0 = Math.max(0, bbox.x - padX);
    cy0 = Math.max(0, bbox.y - padY);
    cw  = Math.min(productCanvas.width  - cx0, bbox.w + padX * 2);
    ch  = Math.min(productCanvas.height - cy0, bbox.h + padY * 2);
  } else {
    cx0 = 0; cy0 = 0;
    cw = productCanvas.width;
    ch = productCanvas.height;
  }

  // ---- STEP 2: fit mode 에 따라 배치 결정 ----
  const productAR = cw / ch;
  const safeAR    = safeW / safeH;

  let drawW, drawH, drawX, drawY;
  let srcX = cx0, srcY = cy0, srcW = cw, srcH = ch;

  if (fitMode === "cover") {
    // safe area 를 완전히 채움. source 의 한 축이 잘림.
    // overlap: 경계 블렌딩을 위해 safe area 밖으로 살짝 확장 (top/bottom 만, sides 는 그대로)
    // — AI 가 받는 입력에서 경계선 영역에 source 픽셀이 살짝 노출되어
    //   배경 연장 결과가 부드럽게 이어짐.
    const overlap = opts.boundaryOverlap != null ? opts.boundaryOverlap : 0;
    drawW = safeW * margin;
    drawH = safeH * margin;
    drawX = safeCx - drawW / 2;
    drawY = safeCy - drawH / 2;
    // safe area 가 top 가장자리 가 아니면 위로 overlap 만큼 확장
    const canExtendTop = safeY > overlap;
    const canExtendBot = (safeY + safeH) + overlap < H;
    if (canExtendTop) { drawY -= overlap; drawH += overlap; }
    if (canExtendBot) { drawH += overlap; }
    // source 의 sub-region 계산: drawW/drawH 와 같은 비율을 source 에서 잘라야 함
    const scale = Math.min(cw / drawW, ch / drawH);
    srcW = drawW * scale;
    srcH = drawH * scale;
    srcX = cx0 + (cw - srcW) / 2;
    srcY = cy0 + (ch - srcH) / 2;
    // source 경계 안으로 clamp (overlap 때문에 음수 가능)
    if (srcX < 0) { drawX -= srcX / scale; drawW += srcX / scale; srcW += srcX; srcX = 0; }
    if (srcY < 0) { drawY -= srcY / scale; drawH += srcY / scale; srcH += srcY; srcY = 0; }
    if (srcX + srcW > sourceImg.width)  srcW = sourceImg.width  - srcX;
    if (srcY + srcH > sourceImg.height) srcH = sourceImg.height - srcY;
  } else {
    // contain: source 전체가 safe area 안에 들어감.
    if (productAR > safeAR) {
      drawW = safeW * margin;
      drawH = drawW / productAR;
    } else {
      drawH = safeH * margin;
      drawW = drawH * productAR;
    }
    drawX = safeCx - drawW / 2;
    drawY = safeCy - drawH / 2;
  }

  // ---- STEP 3: 캔버스에 product 영역만 잘라서 그림 ----
  const c = document.createElement("canvas");
  c.width = W; c.height = H;
  const ctx = c.getContext("2d");
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high";
  // mode 에 따라 drawCanvas 가 달라짐:
  //  - "product": product layer (배경 alpha=0) — safeArea 안에 silhouette 만
  //  - "clean-bg": 상품 erase 후 inpaint 된 clean bg — AI 가 받을 reference
  //  - "tight-product": tight silhouette — 최종 overlay 용
  ctx.drawImage(drawCanvas, srcX, srcY, srcW, srcH, drawX, drawY, drawW, drawH);

  return {
    dataUrl: c.toDataURL("image/png"),
    safe: {
      x1: safeX, y1: safeY,
      x2: safeX + safeW, y2: safeY + safeH,
    },
    placement: { x: drawX, y: drawY, w: drawW, h: drawH },
    fitMode,
    productBbox: {
      detected,
      confidence: bbox?.confidence || 0,
      sourceRegion: { x: srcX, y: srcY, w: srcW, h: srcH },
    },
    extract: productLayer,
  };
}

async function blobToCanvas(blob, targetW, targetH) {
  const url = URL.createObjectURL(blob);
  try {
    const img = await new Promise((res, rej) => {
      const i = new Image();
      i.onload = () => res(i);
      i.onerror = rej;
      i.src = url;
    });
    const c = document.createElement("canvas");
    c.width = targetW * RENDER_SCALE;
    c.height = targetH * RENDER_SCALE;
    const ctx = c.getContext("2d");
    ctx.drawImage(img, 0, 0, c.width, c.height);
    return c;
  } finally {
    URL.revokeObjectURL(url);
  }
}

/* ------------------------------------------------------------
   LOCAL OUTPAINTING (fallback)
   ------------------------------------------------------------
   API 가 꺼져있을 때 사용하는 로컬 미리보기. 진짜 AI outpainting 은
   아니지만 다음을 지킨다:
     1) placeProductInSafeArea 와 동일한 로직으로 product 를 정확히
        safe area 에 배치 (cover/contain 옵션, bbox 검출 모두 적용)
     2) 빈 영역(transparent) 은 product 의 가장자리 픽셀을 늘려서 채움
        (edge extension / smear) — AI 결과와 유사한 톤·라이팅 매칭
   ------------------------------------------------------------ */
async function generateBannerLocal(sourceImg, spec, opts = {}) {
  const {
    variation = "natural",
    grain = true,
    productMargin = 0.95,
    fitMode = "contain",
  } = opts;

  // STEP 1: 표준 safe-area placement
  const preplaced = placeProductInSafeArea(sourceImg, spec, { margin: productMargin, fitMode });
  const { placement } = preplaced;

  const W = spec.w * RENDER_SCALE;
  const H = spec.h * RENDER_SCALE;
  const c = document.createElement("canvas");
  c.width = W; c.height = H;
  const ctx = c.getContext("2d");
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high";

  // STEP 2: 빈 영역을 product 의 가장자리에서 늘려서 채우기 (edge extension)
  // 상단 빈 영역: product 의 첫 줄을 위로 stretch
  // 하단 빈 영역: product 의 마지막 줄을 아래로 stretch
  // 좌우 빈 영역: 첫/마지막 열을 양옆으로 stretch

  // 먼저 placed product 만 그린 임시 캔버스 만들기
  const productOnly = document.createElement("canvas");
  productOnly.width = W; productOnly.height = H;
  const poCtx = productOnly.getContext("2d");
  const placedImg = new Image();
  await new Promise((res, rej) => {
    placedImg.onload = res; placedImg.onerror = rej;
    placedImg.src = preplaced.dataUrl;
  });
  poCtx.drawImage(placedImg, 0, 0);

  const px = Math.round(placement.x);
  const py = Math.round(placement.y);
  const pw = Math.round(placement.w);
  const ph = Math.round(placement.h);

  // ============================================================
  // SAFE AREA 외 영역 처리 — NEUTRAL BACKDROP ONLY (모든 spec 공통)
  // ------------------------------------------------------------
  // 정책: safe area 바깥은 AI 가 자연스러운 배경을 채워야 한다 (사용자 요구).
  // edge-extension / smear / 가장자리 stretch 는 어떤 spec 에서도 절대 금지.
  // 로컬 폴백(AI 끄거나 AI 가 픽셀을 비워둘 때 깔리는 safety net)도
  // edge-extension 을 쓰지 않고, product 가장자리에서 톤만 샘플링한
  // neutral backdrop 으로 채운다.
  // AI 가 정상 응답하면 이 backdrop 위에 결과가 덮여서 보이지 않음.
  // AI 가 실패해 검은 블록을 반환하면 이 backdrop 이 그 자리에 드러남.
  // ============================================================
  const sample = poCtx.getImageData(
    Math.max(0, px), Math.max(0, py),
    Math.max(1, Math.min(W - Math.max(0, px), pw)),
    Math.max(1, Math.min(H - Math.max(0, py), ph))
  );
  const sd = sample.data;
  let sr = 0, sg = 0, sb = 0, sn = 0;
  const EDGE = 12;
  const sw = sample.width, sh = sample.height;
  for (let y = 0; y < sh; y++) {
    const onEdgeY = y < EDGE || y >= sh - EDGE;
    for (let x = 0; x < sw; x++) {
      const onEdgeX = x < EDGE || x >= sw - EDGE;
      if (!onEdgeX && !onEdgeY) continue;
      const i = (y * sw + x) * 4;
      if (sd[i + 3] < 200) continue;
      sr += sd[i]; sg += sd[i+1]; sb += sd[i+2]; sn++;
    }
  }
  if (sn === 0) { sr = 235; sg = 235; sb = 235; sn = 1; }
  sr = Math.round(sr / sn); sg = Math.round(sg / sn); sb = Math.round(sb / sn);
  // neutral 쪽으로 살짝 끌어당김 (#eeeeee 60%)
  sr = Math.round(sr * 0.4 + 238 * 0.6);
  sg = Math.round(sg * 0.4 + 238 * 0.6);
  sb = Math.round(sb * 0.4 + 238 * 0.6);

  // 부드러운 방향성 그라디언트로 backdrop 채움
  // — 가로 우세 spec(aspect>1.6)이면 좌→우, 그 외엔 상→하
  const aspect = W / H;
  const grad = aspect > 1.6
    ? ctx.createLinearGradient(0, 0, W, 0)
    : ctx.createLinearGradient(0, 0, 0, H);
  grad.addColorStop(0,   `rgb(${Math.max(0,sr-12)},${Math.max(0,sg-12)},${Math.max(0,sb-12)})`);
  grad.addColorStop(0.5, `rgb(${sr},${sg},${sb})`);
  grad.addColorStop(1,   `rgb(${Math.max(0,sr-18)},${Math.max(0,sg-18)},${Math.max(0,sb-18)})`);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);

  // product 를 backdrop 위에 그대로 overlay (스트레치/smear 0)
  ctx.drawImage(productOnly, 0, 0);

  // STEP 5: variation tint (옵션)
  if (variation === "studio") {
    const vg = ctx.createRadialGradient(W/2, H/2, Math.min(W,H)*0.3, W/2, H/2, Math.max(W,H)*0.75);
    vg.addColorStop(0, "rgba(0,0,0,0)");
    vg.addColorStop(1, "rgba(0,0,0,0.18)");
    ctx.fillStyle = vg;
    ctx.globalCompositeOperation = "source-over";
    ctx.fillRect(0, 0, W, H);
    ctx.globalCompositeOperation = "source-over";
  }

  // STEP 6: 미세 grain
  if (grain) {
    const imgData = ctx.getImageData(0, 0, W, H);
    const d = imgData.data;
    const a = 0.02;
    for (let i = 0; i < d.length; i += 4) {
      const n = (Math.random() - 0.5) * 255 * a;
      d[i]   = Math.max(0, Math.min(255, d[i]   + n));
      d[i+1] = Math.max(0, Math.min(255, d[i+1] + n));
      d[i+2] = Math.max(0, Math.min(255, d[i+2] + n));
    }
    ctx.putImageData(imgData, 0, 0);
  }

  return c;
}

/* ------------------------------------------------------------
   Top-level generator: AI is REQUIRED when enabled.
   No silent local fallback — errors bubble up so the UI can
   surface "AI 호출 실패" to the user.
   ------------------------------------------------------------ */
async function generateBanner(sourceImg, sourceDataUrl, spec, shotSpec, opts = {}) {
  const { apiConfig, onProgress = () => {}, productMargin, fitMode, inputDataUrl, ...localOpts } = opts;

  // 추출된 상품 layer 를 미리 계산 — 최종 합성의 최상위 preserve layer 로 사용.
  // 같은 함수가 AI 호출 / fallback 안에서도 다시 호출되지만 입력이 동일하므로
  // 결과는 결정론적 (같은 픽셀). 중복 호출 비용은 ms 단위.
  const _preplacedTop = (() => {
    try {
      return placeProductInSafeArea(sourceImg, spec, {
        margin: productMargin != null ? productMargin : 0.95,
        fitMode: "contain",
      });
    } catch { return null; }
  })();

  // 상품 preserve layer (배경 alpha=0, safe area 안 contain-fit 상품 silhouette)
  // 를 finalCanvas 위에 그려서 AI 의 상품 재생성/변형을 무효화한다.
  const overlayPreservedProduct = async (finalCanvas) => {
    if (!_preplacedTop) return;
    try {
      const productLayer = new Image();
      await new Promise((res, rej) => {
        productLayer.onload = res; productLayer.onerror = rej;
        productLayer.src = _preplacedTop.dataUrl;
      });
      finalCanvas.getContext("2d").drawImage(productLayer, 0, 0);
    } catch (e) { console.warn("preserve overlay failed", e); }
  };

  // -------- isFullCanvas 처리 --------
  // Exhibition 처럼 safe area = 캔버스 전체인 경우, 별도 분기 없이 일반
  // AI 파이프라인을 그대로 탄다. placeProductInSafeArea 가 source 를
  // contain-fit 으로 캔버스 전체에 배치하면 product silhouette 주변의
  // 투명 영역(배경 제거 결과)을 AI 가 자연스럽게 outpaint 한다.
  // 이전 early-exit 분기는 회색 backdrop 만 깔려서 "safe area = 회색 박스"
  // 처럼 보이는 시각 결함이 있었으므로 제거.

  // If API is enabled, AI is required. Throw on failure.
  if (apiConfig && apiConfig.enabled) {
    if (!apiConfig.endpoint) {
      throw new Error("Endpoint URL이 비어있어요. API 탭에서 Cloudflare Worker 주소를 입력하세요.");
    }
    onProgress(10);

    // -------- SAFETY NET: pre-render local backdrop --------
    // AI 가 일부 영역을 검은색으로 비워서 반환하는 경우에만 이 backdrop 이
    // 비쳐 보인다. 정상 응답이면 AI 캔버스가 전체를 덮어서 보이지 않음.
    // backdrop 자체에는 어떤 시각적 "박스" 도 없음 (전체 캔버스 균일 톤).
    let fallbackCanvas;
    try {
      fallbackCanvas = await generateBannerLocal(sourceImg, spec, { ...localOpts, productMargin, fitMode });
    } catch (e) {
      console.warn("fallback prep failed", e);
      fallbackCanvas = null;
    }
    onProgress(35);

    const blob = await callGeminiOutpaintAPI(sourceDataUrl, spec, shotSpec, apiConfig, sourceImg, { productMargin, fitMode, inputDataUrl });
    onProgress(85);
    const aiCanvas = await blobToCanvas(blob, spec.w, spec.h);

    // AI 가 픽셀을 비워서 검은 블록으로 반환하면 그 픽셀만 투명으로 바꿔
    // fallback 의 neutral backdrop 이 비쳐 보이도록. 보호 영역은 safe area
    // 사각형이 아니라 "상품 픽셀 자체" — _preplacedTop 의 alpha 마스크 사용.
    let productMaskCanvas = null;
    if (_preplacedTop) {
      try {
        const pm = document.createElement("canvas");
        pm.width  = spec.w * RENDER_SCALE;
        pm.height = spec.h * RENDER_SCALE;
        const pmImg = new Image();
        await new Promise((res, rej) => {
          pmImg.onload = res; pmImg.onerror = rej;
          pmImg.src = _preplacedTop.dataUrl;
        });
        pm.getContext("2d").drawImage(pmImg, 0, 0);
        productMaskCanvas = pm;
      } catch (e) { console.warn("product mask prep failed", e); }
    }
    cleanseBlackInMaskArea(aiCanvas, productMaskCanvas);

    // 합성: fallback(neutral backdrop + 추출된 상품) 위에 AI 결과 얹기.
    //   - safeArea 는 invisible positioning guide (시각 요소 X)
    //   - 상품 = extractProductLayer 로 배경 제거된 silhouette
    //   - 배경 = AI 가 캔버스 전체에 자연스럽게 outpaint
    let finalCanvas;
    if (fallbackCanvas) {
      finalCanvas = fallbackCanvas;
      const ctx = finalCanvas.getContext("2d");
      ctx.drawImage(aiCanvas, 0, 0);
    } else {
      finalCanvas = aiCanvas;
    }

    // PRESERVE LAYER: 최상위에 추출된 원본 상품 다시 덮어쓰기.
    // AI 가 어떻게 상품을 그렸든 (다른 상품으로 재해석 / 비율 변형 / 로고 변경)
    // 이 한 줄로 모두 무효화된다. 상품 = 원본 픽셀 100% 보존.
    await overlayPreservedProduct(finalCanvas);

    onProgress(100);
    return { canvas: finalCanvas, source: "ai" };
  }

  // API disabled → local fallback (only used when user explicitly turns AI off)
  const steps = [25, 50, 75, 92];
  for (const p of steps) {
    await new Promise(r => setTimeout(r, 100 + Math.random() * 120));
    onProgress(p);
  }
  const canvas = await generateBannerLocal(sourceImg, spec, { ...localOpts, productMargin, fitMode });
  onProgress(100);
  return { canvas, source: "local" };
}

function canvasToBlob(canvas, type = "image/png", quality = 0.95) {
  return new Promise(res => canvas.toBlob(b => res(b), type, quality));
}

/* ------------------------------------------------------------
   cleanseBlackInMaskArea — AI 결과에서 검은 빈 영역을 제거.
   ------------------------------------------------------------
   원칙: safe area 사각형은 보호 영역이 아니다. AI 가 보호해야 하는
   유일한 영역은 "상품 오브젝트의 실제 픽셀" (productMask.alpha > 0).
   그 외 모든 픽셀에서 AI 가 near-black 을 반환했다면 fallback 의
   neutral backdrop 이 비쳐 보이도록 alpha=0 으로 바꾼다.

   이전 버전은 safe area 내부 전체를 보호해서, safe area 안에 있는
   상품 주변 마진이 검게/회색 사각형으로 남는 결함이 있었음. 이제 상품
   silhouette 만 보호하고 나머지(상품 주변 마진 포함) 는 모두 cleanse.
   ------------------------------------------------------------ */
function cleanseBlackInMaskArea(canvas, productMaskCanvas) {
  const W = canvas.width, H = canvas.height;
  const ctx = canvas.getContext("2d");
  const imgData = ctx.getImageData(0, 0, W, H);
  const d = imgData.data;
  const DARK = 22; // r/g/b 가 모두 22 이하면 near-black 으로 판단

  // 상품 픽셀 마스크 — productMaskCanvas 의 alpha > 0 인 위치는 보호.
  // productMaskCanvas 가 없으면 보호 없이 모든 near-black 픽셀을 cleanse.
  let pmData = null;
  if (productMaskCanvas && productMaskCanvas.width === W && productMaskCanvas.height === H) {
    try {
      pmData = productMaskCanvas.getContext("2d").getImageData(0, 0, W, H).data;
    } catch { pmData = null; }
  }

  for (let i = 0; i < d.length; i += 4) {
    // 상품 픽셀이면 보호
    if (pmData && pmData[i + 3] > 0) continue;
    if (d[i] < DARK && d[i + 1] < DARK && d[i + 2] < DARK) {
      d[i + 3] = 0; // alpha → 0 (transparent)
    }
  }
  ctx.putImageData(imgData, 0, 0);
}

/* ============================================================
   HYBRID PIPELINE — Master + Smart Crop
   ============================================================
   현재 "사이즈마다 AI 호출" 구조의 비용/시간 문제를 해결하기 위한
   대체 파이프라인. 동작:
     1. 마스터 이미지 1장 (A타입은 source 그대로, B타입은 compose 결과)
     2. detectProductBbox() — 캔버스 픽셀 분석으로 상품 영역 검출
     3. smartCrop() — 각 배너 spec 의 비율로 bbox 를 중심에 두고 crop
        + bbox 가 잘리지 않도록 crop window 자동 조정
   결과: AI 호출 0회 추가, 즉시 생성, 상품 클리핑 위험 ↓.
   ============================================================ */

/* 상품 bbox 검출 (background subtraction)
   ----
   다운샘플된 그리드에서 코너 픽셀들의 평균을 "배경색"으로 가정하고,
   그 색과 충분히 다른 픽셀들의 bounding box 를 상품 영역으로 본다.
   완벽하진 않지만 일반적인 e-commerce 제품 컷에서 잘 동작한다.
   투명 PNG 가 들어오면 alpha 채널을 우선 사용한다.
   */
function detectProductBbox(img) {
  const GRID = 64;
  const c = document.createElement("canvas");
  c.width = GRID; c.height = GRID;
  const ctx = c.getContext("2d");
  ctx.drawImage(img, 0, 0, GRID, GRID);
  const d = ctx.getImageData(0, 0, GRID, GRID).data;

  // ---- Try alpha channel first ----
  let hasTransparency = false;
  for (let i = 3; i < d.length; i += 4) {
    if (d[i] < 250) { hasTransparency = true; break; }
  }

  let minX = GRID, minY = GRID, maxX = -1, maxY = -1;

  if (hasTransparency) {
    // Use alpha — any pixel with alpha > threshold counts as product
    const ALPHA_THRESHOLD = 30;
    for (let y = 0; y < GRID; y++) {
      for (let x = 0; x < GRID; x++) {
        const i = (y * GRID + x) * 4;
        if (d[i + 3] > ALPHA_THRESHOLD) {
          if (x < minX) minX = x;
          if (x > maxX) maxX = x;
          if (y < minY) minY = y;
          if (y > maxY) maxY = y;
        }
      }
    }
  } else {
    // Background-color subtraction
    // Sample 12 corner/edge points
    const samples = [
      [0,0], [GRID-1,0], [0,GRID-1], [GRID-1,GRID-1],
      [GRID>>1, 0], [GRID>>1, GRID-1], [0, GRID>>1], [GRID-1, GRID>>1],
      [4,4], [GRID-5,4], [4,GRID-5], [GRID-5,GRID-5],
    ];
    let bgR = 0, bgG = 0, bgB = 0;
    for (const [x, y] of samples) {
      const i = (y * GRID + x) * 4;
      bgR += d[i]; bgG += d[i+1]; bgB += d[i+2];
    }
    bgR /= samples.length; bgG /= samples.length; bgB /= samples.length;
    const COLOR_THRESHOLD = 36; // empirically tuned
    for (let y = 0; y < GRID; y++) {
      for (let x = 0; x < GRID; x++) {
        const i = (y * GRID + x) * 4;
        const dr = d[i] - bgR, dg = d[i+1] - bgG, db = d[i+2] - bgB;
        const dist = Math.sqrt(dr*dr + dg*dg + db*db);
        if (dist > COLOR_THRESHOLD) {
          if (x < minX) minX = x;
          if (x > maxX) maxX = x;
          if (y < minY) minY = y;
          if (y > maxY) maxY = y;
        }
      }
    }
  }

  // No content detected → assume centered
  if (maxX < 0) {
    return {
      x: img.width * 0.2, y: img.height * 0.2,
      w: img.width * 0.6, h: img.height * 0.6,
      cx: img.width / 2, cy: img.height / 2,
      confidence: 0,
      method: "fallback-center",
    };
  }

  // Expand by 1 cell to compensate for grid quantization
  minX = Math.max(0, minX - 1);
  minY = Math.max(0, minY - 1);
  maxX = Math.min(GRID - 1, maxX + 1);
  maxY = Math.min(GRID - 1, maxY + 1);

  // Scale back to image coords
  const sx = img.width / GRID;
  const sy = img.height / GRID;
  const x = minX * sx;
  const y = minY * sy;
  const w = (maxX - minX + 1) * sx;
  const h = (maxY - minY + 1) * sy;
  const area = ((maxX - minX) * (maxY - minY)) / (GRID * GRID);
  return {
    x, y, w, h,
    cx: x + w / 2, cy: y + h / 2,
    confidence: Math.min(1, area * 3), // 33% coverage → confidence 1
    method: hasTransparency ? "alpha" : "bg-subtract",
  };
}

/* smartCrop — bbox-aware center crop
   ----
   master 이미지에서 target spec 비율로 crop window 를 계산. bbox 중심에
   맞추되, bbox 가 잘리지 않도록 window 위치를 자동 조정한다. master 가
   비율을 못 채우면 fits=false 를 반환 (caller 가 letterbox 또는 outpaint
   fallback 으로 처리).
   ----
   반환값:
     { sx, sy, sw, sh, fits, clippedAxes:[], scale }
       sx/sy/sw/sh — master 픽셀 좌표의 crop 영역
       fits        — bbox 전체가 crop 영역 안에 들어가면 true
       clippedAxes — ["x"], ["y"], ["x","y"] 중 하나 (잘린 축)
       scale       — master 해상도 / spec 해상도 비율 (다운스케일 여유)
*/
function smartCrop(master, targetSpec, bbox) {
  const W = master.width, H = master.height;
  const targetAR = targetSpec.w / targetSpec.h;
  const masterAR = W / H;

  // 1) target 비율에 맞는 최대 crop 윈도우 사이즈를 master 안에서 결정
  let sw, sh;
  if (targetAR > masterAR) {
    // target 이 가로로 더 김 → width 가 master 전체, height 가 줄어듦
    sw = W;
    sh = W / targetAR;
  } else {
    // target 이 세로로 더 김 (대부분의 mobile banner)
    sh = H;
    sw = H * targetAR;
  }

  // 2) bbox 중심에 crop window 를 둠
  let cx = bbox.cx, cy = bbox.cy;

  // 3) bbox 가 crop window 보다 작으면 정상. 크면 fit 불가
  const bboxFitsWidth  = bbox.w <= sw;
  const bboxFitsHeight = bbox.h <= sh;

  let sx = cx - sw / 2;
  let sy = cy - sh / 2;

  // 4) bbox 가 fit 하는 축은 window 가 bbox 를 완전히 포함하도록 nudge
  if (bboxFitsWidth) {
    // bbox 좌측 가장자리가 window 좌측보다 안쪽에 있는지
    if (bbox.x < sx) sx = bbox.x;
    // 우측도 마찬가지
    if (bbox.x + bbox.w > sx + sw) sx = bbox.x + bbox.w - sw;
  }
  if (bboxFitsHeight) {
    if (bbox.y < sy) sy = bbox.y;
    if (bbox.y + bbox.h > sy + sh) sy = bbox.y + bbox.h - sh;
  }

  // 5) master 경계 안으로 clamp
  sx = Math.max(0, Math.min(W - sw, sx));
  sy = Math.max(0, Math.min(H - sh, sy));

  const clippedAxes = [];
  if (!bboxFitsWidth)  clippedAxes.push("x");
  if (!bboxFitsHeight) clippedAxes.push("y");

  const scale = Math.min(sw / targetSpec.w, sh / targetSpec.h);
  return {
    sx, sy, sw, sh,
    fits: clippedAxes.length === 0,
    clippedAxes,
    scale, // >1 means master has more pixels than target (good, downsample)
  };
}

/* generateBannerFromMaster — pure-canvas pipeline.
   ----
   master 이미지에서 spec 비율로 smart-crop → canvas 에 다시 그려 target
   사이즈 × RENDER_SCALE 으로 출력. AI 호출 0회.
   ----
   반환: { canvas, source: "smart-crop", crop }
*/
async function generateBannerFromMaster(masterImg, spec, opts = {}) {
  const {
    bbox = null,
    grain = false,
    onProgress = () => {},
  } = opts;

  onProgress(20);
  const detected = bbox || detectProductBbox(masterImg);
  onProgress(45);
  const crop = smartCrop(masterImg, spec, detected);
  onProgress(70);

  const W = spec.w * RENDER_SCALE;
  const H = spec.h * RENDER_SCALE;
  const c = document.createElement("canvas");
  c.width = W; c.height = H;
  const ctx = c.getContext("2d");
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high";

  // Draw cropped region into target canvas
  ctx.drawImage(masterImg, crop.sx, crop.sy, crop.sw, crop.sh, 0, 0, W, H);

  // Optional light grain for realism (only if downsampling — i.e. master is
  // bigger than target — to avoid double-graining a small master).
  if (grain && crop.scale > 1.2) {
    const imgData = ctx.getImageData(0, 0, W, H);
    const d = imgData.data;
    const a = 0.02;
    for (let i = 0; i < d.length; i += 4) {
      const n = (Math.random() - 0.5) * 255 * a;
      d[i]   = Math.max(0, Math.min(255, d[i]   + n));
      d[i+1] = Math.max(0, Math.min(255, d[i+1] + n));
      d[i+2] = Math.max(0, Math.min(255, d[i+2] + n));
    }
    ctx.putImageData(imgData, 0, 0);
  }

  onProgress(100);
  return {
    canvas: c,
    source: "smart-crop",
    crop: { ...crop, bbox: detected },
  };
}

/* ------------------------------------------------------------
   composeProducts — B타입 전처리
   N개 상품 이미지를 받아 AI(Gemini)에게 "하나의 합성 정방형 이미지"를
   만들도록 시킨다. 결과 dataUrl 을 반환한다.
   API 비활성 상태면 로컬 캔버스 콜라주를 폴백으로 만들어줘서 흐름 테스트
   가 가능하다 (실제 AI 합성과는 다름 — 라벨로 구분).
   ------------------------------------------------------------ */
async function composeProducts(productDataUrls, opts = {}) {
  const {
    apiConfig,
    prompt = "",
    width = 1024,
    height = 1024,
  } = opts;

  if (!Array.isArray(productDataUrls) || productDataUrls.length === 0) {
    throw new Error("최소 1개 이상의 상품 이미지가 필요합니다.");
  }

  // ---- LOCAL FALLBACK ----
  // API 가 꺼져있거나 endpoint 가 없으면 캔버스 콜라주로 흐름 테스트용
  // 합성본을 만든다. 진짜 AI 합성은 아님.
  if (!apiConfig || !apiConfig.enabled || !apiConfig.endpoint) {
    const dataUrl = await composeProductsLocal(productDataUrls, { width, height });
    // 반환값에 marker 표시할 수도 있지만, 호출부 시그니처를 유지하기 위해 dataUrl 만 반환.
    return dataUrl;
  }

  // User-authored prompt is the primary creative direction. We surround it
  // with minimal hard rules (output size + number of inputs + no text/logo
  // additions). The intent is that the user can fully control style by
  // editing the prompt textarea — don't fight them with auto-injected rules.
  const userBlock = (prompt && prompt.trim()) ? prompt.trim() : "";

  const directionLines = [
    `# OUTPUT REQUIREMENT`,
    `Generate ONE photo-real image at exactly ${width}×${height} pixels.`,
    `Input: ${productDataUrls.length} reference product image(s).`,
    ``,
    `# USER-AUTHORED CREATIVE DIRECTION (highest priority)`,
    userBlock || `Treat the ${productDataUrls.length} items as ONE hero shot, in one coherent photograph. Balanced editorial composition. Unified background and lighting. Result must look like a single real photograph, not a collage.`,
    ``,
    `# NON-NEGOTIABLE GUARDRAILS`,
    `- Output must be photo-real, not illustration or 3D render.`,
    `- Do NOT add any text, logos, watermarks, captions, prices, or badges.`,
    `- Do NOT add unrelated extra props beyond what the user's direction requests.`,
    `- The composed image will be extended into 4 banner aspect ratios afterward — leave generous breathing room around the cluster (don't fill corners).`,
    `- Background must be calm enough that headline text could overlay later.`,
    ``,
    `# COMPOSITION CONSTRAINTS (CRITICAL — fix the Hero 1:1 result)`,
    `- Place ALL products centered in the frame as a balanced cluster. Geometric center of the cluster MUST be at the canvas center (50%, 50%).`,
    `- Do NOT push products to the edge of the frame. Do NOT crop any product.`,
    `- Maintain each product's original aspect ratio. NEVER stretch, squash, or distort.`,
    `- Each product must be fully visible (no clipping, no partial cutoff).`,
    `- Output must be exactly square (1:1). Products fit within the central ~70% of the canvas, leaving ~15% margin on each side as breathing room for later outpainting.`,
    `- If the products together are wide, scale the cluster smaller — do NOT crop sides. If tall, scale smaller — do NOT crop top/bottom.`,
    `- No tilted/skewed layouts. Products sit on a level horizontal plane (if shadows are used).`,
  ].join("\n");

  const res = await fetch(apiConfig.endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      mode: "compose",
      dataUrls: productDataUrls,
      prompt: directionLines,
      width,
      height,
    }),
  });
  if (!res.ok) {
    let detail = "";
    try { detail = await res.text(); } catch {}
    throw new Error(`Proxy ${res.status}: ${detail.slice(0, 200)}`);
  }
  const json = await res.json();
  if (!json.url) throw new Error("Proxy did not return an image URL");

  // The compose endpoint returns a data: URL — return it directly so the
  // caller can use it as a source image for the standard A-type pipeline.
  return json.url;
}

/* ------------------------------------------------------------
   composeProductsLocal — Canvas-based collage (no AI).
   Used as a fallback so users can verify the B-type UI flow
   (upload → approval gate → batch outpaint) without burning
   Gemini credits or needing the proxy. The result is clearly
   NOT a real composition — it's a tone-matched grid arrangement.
   ------------------------------------------------------------ */
async function composeProductsLocal(productDataUrls, opts = {}) {
  const { width = 1024, height = 1024 } = opts;
  const imgs = await Promise.all(productDataUrls.map(u => new Promise((res, rej) => {
    const i = new Image();
    i.onload = () => res(i);
    i.onerror = rej;
    i.src = u;
  })));

  const c = document.createElement("canvas");
  c.width = width; c.height = height;
  const ctx = c.getContext("2d");
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high";

  // ---- Step 1: sample dominant tone from product corners for backdrop ----
  let r = 0, g = 0, b = 0, n = 0;
  for (const img of imgs) {
    const s = document.createElement("canvas");
    s.width = 16; s.height = 16;
    s.getContext("2d").drawImage(img, 0, 0, 16, 16);
    const d = s.getContext("2d").getImageData(0, 0, 16, 16).data;
    for (let i = 0; i < d.length; i += 4) {
      r += d[i]; g += d[i+1]; b += d[i+2]; n++;
    }
  }
  r = Math.round(r/n); g = Math.round(g/n); b = Math.round(b/n);
  // Soften toward neutral (mix with #eee)
  r = Math.round(r * 0.4 + 238 * 0.6);
  g = Math.round(g * 0.4 + 238 * 0.6);
  b = Math.round(b * 0.4 + 238 * 0.6);

  // ---- Step 2: smooth gradient backdrop ----
  const grad = ctx.createRadialGradient(width*0.5, height*0.35, 0, width*0.5, height*0.5, width*0.8);
  grad.addColorStop(0, `rgb(${Math.min(255,r+10)},${Math.min(255,g+10)},${Math.min(255,b+10)})`);
  grad.addColorStop(1, `rgb(${Math.max(0,r-25)},${Math.max(0,g-25)},${Math.max(0,b-25)})`);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, width, height);

  // ---- Step 3: layout products ----
  // Compute positions for N=1..many products in a balanced cluster.
  const N = imgs.length;
  const layouts = layoutForN(N, width, height);

  // ---- Step 4: draw soft cast shadow + product ----
  layouts.forEach((box, idx) => {
    const img = imgs[idx];
    // Fit image into box preserving aspect ratio
    const ar = img.width / img.height;
    let dw = box.w, dh = box.h;
    if (ar > 1) dh = dw / ar; else dw = dh * ar;
    const dx = box.cx - dw/2;
    const dy = box.cy - dh/2;
    // Shadow
    ctx.save();
    ctx.filter = "blur(18px)";
    ctx.fillStyle = "rgba(0,0,0,0.28)";
    ctx.beginPath();
    ctx.ellipse(box.cx + dw*0.04, dy + dh*0.96, dw*0.42, dh*0.06, 0, 0, Math.PI*2);
    ctx.fill();
    ctx.restore();
    // Product
    ctx.drawImage(img, dx, dy, dw, dh);
  });

  // ---- Step 5: light grain for realism ----
  const imgData = ctx.getImageData(0, 0, width, height);
  const d = imgData.data;
  for (let i = 0; i < d.length; i += 4) {
    const noise = (Math.random() - 0.5) * 6;
    d[i]   = Math.max(0, Math.min(255, d[i]   + noise));
    d[i+1] = Math.max(0, Math.min(255, d[i+1] + noise));
    d[i+2] = Math.max(0, Math.min(255, d[i+2] + noise));
  }
  ctx.putImageData(imgData, 0, 0);

  // ---- Step 6: stamp [LOCAL] watermark so user knows this is fake ----
  ctx.save();
  ctx.fillStyle = "rgba(0,0,0,0.35)";
  ctx.fillRect(width - 220, height - 44, 200, 28);
  ctx.fillStyle = "rgba(255,255,255,0.85)";
  ctx.font = "bold 13px ui-monospace,Menlo,monospace";
  ctx.fillText("LOCAL · 테스트용 합성", width - 210, height - 25);
  ctx.restore();

  return c.toDataURL("image/jpeg", 0.92);
}

// Lay out N products in a "single coherent shot" cluster.
function layoutForN(n, W, H) {
  const cx = W/2, cy = H/2;
  const u = Math.min(W, H);
  if (n === 1) {
    return [{ cx, cy, w: u*0.6, h: u*0.6 }];
  }
  if (n === 2) {
    return [
      { cx: cx - u*0.18, cy, w: u*0.42, h: u*0.42 },
      { cx: cx + u*0.18, cy, w: u*0.42, h: u*0.42 },
    ];
  }
  if (n === 3) {
    // Triangle: back row 2, front-center 1
    return [
      { cx: cx - u*0.20, cy: cy - u*0.08, w: u*0.36, h: u*0.36 },
      { cx: cx + u*0.20, cy: cy - u*0.08, w: u*0.36, h: u*0.36 },
      { cx,              cy: cy + u*0.18, w: u*0.40, h: u*0.40 },
    ];
  }
  if (n === 4) {
    return [
      { cx: cx - u*0.20, cy: cy - u*0.18, w: u*0.34, h: u*0.34 },
      { cx: cx + u*0.20, cy: cy - u*0.18, w: u*0.34, h: u*0.34 },
      { cx: cx - u*0.20, cy: cy + u*0.18, w: u*0.34, h: u*0.34 },
      { cx: cx + u*0.20, cy: cy + u*0.18, w: u*0.34, h: u*0.34 },
    ];
  }
  // 5+ → grid auto-fit, max 3 columns
  const cols = Math.min(3, Math.ceil(Math.sqrt(n)));
  const rows = Math.ceil(n / cols);
  const cellW = (W * 0.78) / cols;
  const cellH = (H * 0.78) / rows;
  const startX = (W - cellW * cols) / 2 + cellW/2;
  const startY = (H - cellH * rows) / 2 + cellH/2;
  const out = [];
  for (let i = 0; i < n; i++) {
    const r = Math.floor(i / cols);
    const col = i % cols;
    out.push({
      cx: startX + col * cellW,
      cy: startY + r * cellH,
      w: cellW * 0.85,
      h: cellH * 0.85,
    });
  }
  return out;
}

/* ------------------------------------------------------------
   Shot spec auto-generation via window.claude.complete.
   ------------------------------------------------------------ */
async function generateShotSpec(sourceDataUrl, brandHint = "") {
  const prompt = `You are a senior art director. Given a square source product banner, output a JSON shot_spec describing the mood, source lighting, and per-banner placement directives for 4 banners (375x540, 335x335, 150x332, 197x482). Reply ONLY with compact JSON matching this shape (no markdown):
{"shared":{"mood":"...","source_lighting_note":"..."},"shots":[{"banner":"BeautyHomeMainBanner","aspect":"375x540","allowed_zone":"...","anchor":"...","background_treatment":"...","composition_rule":"...","negative_space_note":"..."},{"banner":"PromotionCategoryBanner","aspect":"335x335",...},{"banner":"PromotionRankingBanner","aspect":"150x332",...},{"banner":"BeautyPromoBanner","aspect":"197x482",...}]}
Brand hint: ${brandHint || "(none — infer from source)"}`;

  try {
    if (window.claude && window.claude.complete) {
      const txt = await window.claude.complete(prompt);
      const cleaned = txt.replace(/```json|```/g, "").trim();
      const m = cleaned.match(/\{[\s\S]*\}/);
      if (m) {
        const parsed = JSON.parse(m[0]);
        // Always seed user_directive fields so the UI textareas are controlled
        if (parsed.shared && parsed.shared.user_directive == null) parsed.shared.user_directive = "";
        if (Array.isArray(parsed.shots)) {
          parsed.shots.forEach(s => { if (s.user_directive == null) s.user_directive = ""; });
        }
        return parsed;
      }
    }
  } catch (e) {
    console.warn("Claude shot_spec failed, using fallback", e);
  }

  return {
    shared: {
      mood: "Clean editorial · soft product hero",
      source_lighting_note: "Key light from upper-right ~45°, soft shadow extending lower-left. All extensions follow this direction.",
      user_directive: "",
    },
    shots: BANNER_SPECS.map(s => ({
      banner: s.id,
      aspect: `${s.w}x${s.h}`,
      user_directive: "",
      allowed_zone: `x:${(s.allowed.x*100).toFixed(0)}%-${((s.allowed.x+s.allowed.w)*100).toFixed(0)}% y:${(s.allowed.y*100).toFixed(0)}%-${((s.allowed.y+s.allowed.h)*100).toFixed(0)}%`,
      anchor: `(${(s.anchor.x*100).toFixed(0)}%, ${(s.anchor.y*100).toFixed(0)}%)`,
      background_treatment: "Extend the source backdrop outward — preserve hue and luminance falloff. Red zones stay flat (no motif).",
      product_treatment: {
        preserve: "Color, exposure, contrast — match source exactly",
        shadow_continuity: "Continue source cast shadow direction; blur 15px; opacity 0.32",
        reflection: "Extend source floor reflection if present; do not add one if absent",
        surface: "Seamless to source surface material",
        atmosphere: "No dust/haze/fog added",
      },
      graphic_motif: "None — keep extension neutral for red-zone text legibility",
      composition_rule: s.rule,
      negative_space_note: s.negSpace,
    })),
  };
}

window.BannerGen = {
  BANNER_SPECS,
  RENDER_SCALE,
  generateBanner,
  generateShotSpec,
  composeProducts,
  detectProductBbox,
  smartCrop,
  generateBannerFromMaster,
  placeProductInSafeArea,
  extractProductLayer,
  canvasToBlob,
};
