← back to Wallco Ai

lib/repeat-prompt.js

85 lines

// repeat-prompt — canonical wallpaper-tile prompt builder.
//
// Single source of truth for "render a multi-motif repeating wallpaper tile"
// prompts sent to gemini-2.5-flash-image. Replaces the per-endpoint
// buildGenPrompt helpers that were each independently asking for "the motif"
// (singular) on a ground — which Gemini interpreted as "render ONE motif
// centered on the canvas". That produces a single-hero composition with
// wrap-half artefacts at the corners; mathematically tileable but
// compositionally NOT a wallpaper repeat. See task #5/#6, 2026-05-24.
//
// Public API:
//   buildRepeatPrompt({
//     motifDescription,   // 1-3 sentence description of ONE instance of the motif
//     groundHex,          // '#RRGGBB' or null
//     groundDesc,         // 'warm cream', 'sage green', etc. or null
//     density = 'sparse', // 'sparse' (4-6/tile) | 'medium' (7-12/tile) | 'dense' (12-20/tile)
//     layout  = null,     // 'diamond' | 'brick' | 'drop-half' | 'grid' | null (cycle via attempt)
//     attempt = 0,        // 0..N — picks among phrasing variants for retry
//   }) -> string

const DENSITY_SPEC = {
  sparse: { count: '4 to 6 instances', spacing: 'with generous negative space between them' },
  medium: { count: '7 to 12 instances', spacing: 'evenly spaced with moderate negative space' },
  dense:  { count: '12 to 20 instances', spacing: 'tightly packed in a regular grid' },
};

const LAYOUT_SPEC = {
  diamond:    'diamond half-drop repeat (each row shifted half a column relative to the row above)',
  brick:      'brick offset repeat (each row shifted horizontally by half a motif width)',
  'drop-half': 'drop-half repeat (alternating columns offset vertically by half the row pitch)',
  grid:       'rectilinear grid (motifs in straight rows and columns)',
};
const LAYOUT_CYCLE = ['diamond', 'brick', 'drop-half', 'grid'];

function pickLayout(layout, attempt) {
  if (layout && LAYOUT_SPEC[layout]) return layout;
  return LAYOUT_CYCLE[attempt % LAYOUT_CYCLE.length];
}

function buildRepeatPrompt({ motifDescription, groundHex, groundDesc, density = 'sparse', layout = null, attempt = 0 } = {}) {
  if (!motifDescription || typeof motifDescription !== 'string') {
    throw new Error('repeat-prompt: motifDescription required');
  }
  const dens = DENSITY_SPEC[density] || DENSITY_SPEC.sparse;
  const lay  = pickLayout(layout, attempt);
  const ground = groundDesc && groundHex ? `${groundDesc} (${groundHex})`
                : groundHex             ? groundHex
                : groundDesc            ? groundDesc
                : 'warm cream (#fbf8f1)';

  // Three phrasing variants — same constraints, different verbal framing.
  // Each one MUST explicitly say "multiple instances" + "repeating field" +
  // "no single centered hero" so Gemini cannot fall back to default
  // hero-portrait framing on ambiguous motif descriptions.
  const variants = [
    // Variant A — screenprint / vinyl die-cut framing
    `A wallpaper repeat tile. Multiple identical instances of the SAME motif arranged as a ${LAYOUT_SPEC[lay]}: ${dens.count} per tile, ${dens.spacing}. Each instance: ${motifDescription} Every instance at the same scale, same orientation, same colors — repeated motifs, not one hero. Set against a flat uniform ${ground} ground with zero texture, zero gradient, zero atmospheric depth. Every motif pixel solid color alpha 1.0; every ground pixel the exact same ${ground} tone. Hard razor-sharp silhouette edges, vinyl die-cut sticker aesthetic. The tile is SEAMLESS — motifs continue continuously across the left↔right and top↔bottom edges (a motif partly clipped on the right edge has its other half on the left edge of the same tile). DO NOT render a single large centered hero motif. DO NOT enlarge one instance. Multiple equal small instances only.`,
    // Variant B — stencil framing, count-first
    `Wallpaper field of ${dens.count} of the same motif on a ${ground} ground, arranged in a ${LAYOUT_SPEC[lay]}. Every instance is the same shape, same scale, same color: ${motifDescription} ${dens.spacing.charAt(0).toUpperCase() + dens.spacing.slice(1)}. Single flat ink layer per motif on the solid ground — no shading, no gradients, no atmospheric perspective, no faded duplicates. Edges crisp like a hand-cut stencil. The tile must wrap seamlessly: take the output, place 4 copies in a 2×2 grid, and the seams must be invisible because every motif clipped at an edge continues on the opposite edge. NO single centered hero. NO enlarged feature motif. NO portrait-style composition — this is a wallpaper repeat, not a print.`,
    // Variant C — cut-paper collage framing, layout-first
    `Cut-paper collage wallpaper tile, ${LAYOUT_SPEC[lay]}. The motif (repeated ${dens.count} across the tile, ${dens.spacing}): ${motifDescription} Each cut from solid paper, one opaque color per motif, hard scissor-cut edges, mounted flat on a ${ground} backdrop with no texture. Identical copies of the motif — same scale, same orientation, same color. NOT one large centered figure with empty corners. NOT a portrait. NOT a medallion. A genuine all-over wallpaper repeat where the tile, placed adjacent to copies of itself, produces a continuous pattern with motifs flowing across every edge.`,
  ];

  return variants[attempt % variants.length];
}

// Helper — heuristically pick density from the source design's existing motif
// count (or motif description complexity). Use when the caller doesn't know
// what density the source wallpaper has.
//   - tiny / busy motif (text, geometric, small floral) → 'dense'
//   - mid-scale single-figure (animal silhouette, framed cameo, large flower)
//     → 'sparse'
//   - everything else → 'medium'
function inferDensity(motifDescription) {
  if (!motifDescription) return 'sparse';
  const s = motifDescription.toLowerCase();
  // Hero-scale signals — usually want sparse so each instance has room
  if (/\b(cameo|portrait|medallion|frame|wreath|crest|emblem|crown|throne)\b/.test(s)) return 'sparse';
  // Dense-pattern signals
  if (/\b(geometric|tessellat|chevron|herringbone|small|tiny|dot|grain|texture|micro)\b/.test(s)) return 'dense';
  return 'medium';
}

module.exports = { buildRepeatPrompt, inferDensity, DENSITY_SPEC, LAYOUT_SPEC };