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