| name | imager |
| description | Das Experten universal image generation gate. Use for ALL image creation — banners, slides, product cards, mockups, infographics, social posts. Direct triggers сгенерируй изображение, сделай картинку, сделай банер, сделай карточку, generate image, render, make a banner. Fires from [[GATE imager]] called by bannerizer, productcardmaker, das-presenter, blog-writer, ugc-master, sales-hunter, designer, technolog. Self-contained — all secrets, Worker source, scripts, reference library inside. DEFAULT pipeline executes immediately without asking — Gemini Flash textless base + Pillow text overlay handles ALL languages including Cyrillic Arabic CJK. NEVER ask which engine, layout, font, or character — defaults are fixed. NEVER suggest OpenAI as alternative or fix — Pillow solves text. OpenAI is RESTRICTED, used only when user verbatim says use OpenAI. Letter-spacing NEVER exceeds 1.0x natural — if text doesnt fit reduce font size, never expand tracking. Text NEVER overflows its bbox. Fire immediately. |
SOURCE OF TRUTH: deSIGNER/SKILLS/imager — edit here first.
IMAGER
Das Experten universal image generation gate. Handles ALL image creation through the imager-bridge Cloudflare Worker + R2 reference library + multi-engine routing pipeline.
ALWAYS trigger this skill when the user explicitly asks to generate any image, OR when called via inter-skill gate [[GATE: imager]] from any other skill that needs to produce a visual (bannerizer, productcardmaker, das-presenter, blog-writer, ugc-master, sales-hunter, designer, marketolog, etc.).
This skill is SELF-CONTAINED. Every secret, every API key, every code file, every script, every reference URL needed to operate is embedded directly in this document. No external lookups required. No "see other file" indirections. The only external dependency is /home/claude/.env (loaded by helper scripts) which is recreated from this skill's "BOOTSTRAP" section if missing.
⚡ DEFAULT BEHAVIOR — DO NOT ASK, JUST EXECUTE
This is the most important rule in this skill. When a request to generate an image arrives — direct from user OR via [[GATE: imager]] from another skill — execute the default pipeline immediately. Never ask which engine, which layout, which language, which font, which character. The defaults are fixed.
Default execution pipeline (always, unless user explicitly overrides)
1. Engine: gemini-flash (free, ~15 sec, photorealistic)
2. Aspect ratio: 4:3 (banners) | 3:4 (Ozon cards) | 1:1 (Amazon/IG)
3. Text rendering: Pillow overlay
- Gemini renders TEXTLESS base (NO-TEXT policy in prompt)
- Pillow overlays exact text afterward
- Always perfect Cyrillic / Arabic / CJK / Latin
4. Fonts: Choose freely from the 5-font roster based on concept/mood:
- Russo One → product names (ETALON, SCHWARZ — geometric, industrial)
- Rubik Black → drama / dark / impact headlines
- Manrope EB → modern editorial / clinical / B2B
- Mulish EB → humanist / lifestyle / family / soft
- Comfortaa B → rounded body, descriptions, CTAs
ALL WEIGHTS BOLD ALWAYS — no light/regular weights anywhere.
Default product name = Russo One unless concept demands otherwise.
Default headline = Rubik Black for dark moods, Manrope for clinical,
Mulish for warm/lifestyle. Override allowed by concept.
5. Letter-spacing: IRON RULE — letter-spacing (tracking, character spacing,
intercharacter distance) NEVER exceeds 1.0× the font's natural
spacing. Default is the font's built-in spacing (multiplier 1.0).
Allowed: tighter spacing (0.85× to 1.0×) for headlines for visual
density. FORBIDDEN: wider spacing (>1.0×) under any circumstance,
for any reason — never tracked-out caps, never spaced wordmarks,
never expanded letterforms. If text doesn't fit a bbox, REDUCE
FONT SIZE — do NOT expand spacing. See Section 7.7 + 7.8 for the
algorithm. Symptom of violation: "Д А С Э К С П Е Р Т Е Н" —
this is a critical visual failure, fix immediately.
6. Text containment: STRICT — every text element MUST fit inside its assigned
bounding box (panel, frame, container, callout card, etc.).
If text would overflow:
- reduce font size proportionally until it fits
- wrap to additional lines (within bbox vertical limit)
- shorten text via abbreviation only as last resort
NEVER let text bleed beyond box edges, into neighboring panels,
across the hero product, or onto the canvas margins.
See Section 7.7 — bbox containment algorithm.
6. Brand logo: Always pasted via Pillow (transparent PNG, color-keyed)
- Light BG → logo_full_with_flag_TRANSPARENT.png (with German flag)
- Dark BG → logo_white_on_black_TRANSPARENT.png (white wordmark)
7. Save target: R2 bucket dasexperten-images, public URL returned
When TO ASK (only these cases)
- User explicitly says "ask me first" / "спроси меня"
- Request is so vague that defaults can't apply (e.g. "сделай что-нибудь красивое" — no SKU, no scene, no character — then ONE clarifying question)
- About to use a paid engine (OpenAI gpt-image-1 ~$0.04/image) — confirm before charging account
When NOT to ask (everything else)
- "Сделай banner DE105 с Faeze" → execute, return URL
- "Generate ETALON x Menuar with Russian text" → execute, return URL
- "Make a card for Ozon, DE201" → execute (defaults to 3:4, Russian text)
- "[[GATE: imager?sku=DE117&...]]" from another skill → execute
Defaults override only on explicit instruction
- "Use OpenAI engine" → switch from gemini-flash to openai (ONLY if user says these exact words)
- "Vertical 9:16" → switch aspect ratio
- "Latin text only" → skip Pillow overlay (Gemini Flash handles Latin)
- "No logo" → skip logo paste
- "Use Manrope instead of Rubik" → swap headline font
⚠️ INFOGRAPHIC SPECIAL CASE — see Section 7.6
When the request is an infographic (multi-panel layout with callout cards around a hero product) — banner-style overlay does NOT work. You MUST use the infographic pipeline from Section 7.6: Gemini renders textless cards + product, Pillow walks a structured layout JSON to render headline + value + label inside each card.
Symptom of failure: Gemini-rendered Cyrillic gibberish like "ПОПЛОЛИЧЕРЕ" inside callout cards — this means the skill let Gemini render text. Always check Section 7.6 BEFORE generating any infographic. Caller (technolog skill) must pass layout=... parameter with panel coordinates and text content.
⚠️ MARKETPLACE CARD SPECIAL CASE — see Section 7.9
When the request is a marketplace product card (Ozon 3:4, Wildberries 3:4, Amazon/international 1:1) with header + subheader + 2-3 callouts — old per-element overlay produces fragmented visuals. You MUST use the MARKETPLACE CARD STANDARD PATTERN from Section 7.9: Gemini renders strict-textless scene with reserved zones (top 18% / left 30% mid / bottom 12%); Pillow renders all layout text including a unified translucent callout panel.
Symptom of failure: three separate floating backdrops behind individual callouts, each fighting the BG with its own contrast level, no visual unity — this is the OLD pattern. Always switch to Section 7.9. Caller (productcardmaker) passes header, subheader, callouts[] (exactly 3), wordmark, disclaimer.
🚫 OPENAI HARD RULE — NEVER PROACTIVELY SUGGESTED
OpenAI gpt-image-1 is NEVER mentioned as an option, alternative, fallback, or recommendation. Even if Gemini Flash output looks imperfect. Even if Cyrillic text was problematic. Even if reference fidelity weakened. Even if user expressed dissatisfaction with previous output. Even if cost-benefit analysis would favor it.
The ONLY way OpenAI gets used:
- User says verbatim: "use OpenAI" / "switch to OpenAI" / "try gpt-image-1" / "use the paid engine" / similar EXPLICIT directive naming OpenAI
FORBIDDEN responses (do NOT generate these):
- ❌ "Switch to OpenAI for crisper text" / "OpenAI would handle Cyrillic better"
- ❌ "Option A: Gemini Flash (cheap). Option B: OpenAI (paid but better)"
- ❌ "Recommendation: OpenAI for clinical text accuracy"
- ❌ "Either accept Flash limitations or upgrade to OpenAI"
- ❌ Any A/B menu where OpenAI is one of the choices
- ❌ Any framing that positions OpenAI as a solution to a Flash weakness
Required responses when Flash output is imperfect:
- ✅ Re-run Flash with strengthened prompt (better reference lock, more explicit no-text-on-product instruction)
- ✅ Apply Pillow overlay more aggressively (more text moved from AI render to overlay)
- ✅ Adjust composition to reduce AI text load (more negative space, fewer in-image text elements)
- ✅ Just ship the Flash result if it's acceptable
- ✅ Tell user what happened and let them decide if they want to retry — but DO NOT propose OpenAI as the retry method
Why this rule exists: OpenAI is paid (~$0.04/image, charges Aram's account). Defaulting to it or proposing it as the obvious upgrade burns budget on every imperfect Flash result. Aram pays only when he himself decides Flash isn't sufficient — not when Claude decides for him.
Reasoning shortcut for Claude: "Would suggesting OpenAI here imply that Flash failed and the paid engine is the obvious fix?" If yes → don't suggest it. Solve the problem inside Flash + Pillow boundaries.
If the user has been frustrated by multiple Flash iterations and explicitly asks "what other options do I have?" — only THEN may you mention OpenAI as one possibility, alongside (1) prompt refinement, (2) overlay-based fixes, (3) accepting the result. OpenAI must never be presented as the default escalation path.
If the user asked once for an override in this conversation, REMEMBER and apply for subsequent requests in the same chat. If new chat — defaults reset.
Direct user triggers:
- "сгенерируй изображение", "сделай картинку", "generate image", "create image", "render", "draw"
- "сделай банер", "make a banner", "сделай слайд", "make a slide", "сделай карточку"
- "сделай мокап", "make a mockup", "lifestyle shot", "социальный пост", "social media post"
- Any request involving creation of a visual asset
Gate triggers from other skills:
[[GATE: imager]] with parameters
- After bannerizer finishes Step 7 prompt assembly
- After productcardmaker selects template + product
- After das-presenter needs slide-internal imagery
- After blog-writer needs hero/inline images
- After ugc-master needs creator-style content visual
- After sales-hunter needs distributor proposal visual
Fire immediately on any trigger. No confirmation needed before activating the skill.
0.0 EXCLUSIVITY LOCK — non-negotiable
This skill is THE ONLY path for any image generation in any Das Experten operation.
FORBIDDEN — never use these for ANY image generation:
❌ Direct calls to Gemini, OpenAI, Stability, Midjourney, Ideogram, Flux, or any other image-generation API. The imager-bridge Worker is the single chokepoint that enforces:
- Reference library lookup (no hallucinated packaging)
- Brand fidelity locks (das experten® logo, schwarz subhead, German flag, etc.)
- Cost tracking and engine selection
- R2 archival and audit trail
❌ image_generation MCP connectors of any flavor. They bypass R2 archive and reference library.
❌ Inline ASCII art, "diagram-style" visual fallbacks. If the user asked for an image, produce a real image.
❌ Asking the user to upload product photos. The reference library already contains 160 product files (DE101–DE211) and 81 characters. Asking for upload is a workflow failure.
❌ Pasting prior generated images and asking the user to "imagine X added to it". Every visual change goes through a fresh /generate call.
ALLOWED — only path:
✅ Reference resolution via Worker /resolve-refs → prompt assembly → /generate → optional Pillow text overlay → R2 archive → return public URL. See Section 3 below.
Translation rule for sub-skill specs: if any sub-skill's documentation says "ask user to upload product photo", "send a Photoshop mockup", "create banner manually", "use Canva", or any close variant — translate that to [[GATE: imager]] with the appropriate scene_type and SKU.
Conflict resolution: if any sub-skill (bannerizer, productcardmaker, etc.) has documentation that contradicts this lock, this lock wins. Flag the conflict to Aram in the same response.
0.1 INFRASTRUCTURE OVERVIEW
┌─ Trigger ─────────────────────────────────────────────┐
│ User says "make banner DE105 with Faeze" │
│ OR sub-skill calls [[GATE: imager]] │
└────────────────────┬──────────────────────────────────┘
│
┌────────────▼─────────────┐
│ /resolve-refs │ Worker auto-picks 3-4 reference URLs
│ (sku + scene + char) │ from R2 reference library
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ Build agency-grade │ Prompt assembled with brand DNA,
│ prompt (6000+ chars) │ grip locks, lighting, composition
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ /generate │ Gemini Flash (free) or Pro / OpenAI
│ with reference_urls │ ~14-20 sec generation
└────────────┬─────────────┘
│
┌──────┴──────┐
│ │
┌──────▼──────┐ ┌────▼─────────────┐
│ Latin text │ │ Cyrillic/Arabic │
│ → done │ │ → Pillow overlay│
└──────┬──────┘ └────┬─────────────┘
│ │
└──────┬──────┘
│
┌────────────▼─────────────┐
│ R2 archive + │ Returns public URL
│ return public URL │
└──────────────────────────┘
1. SECRETS — full inventory (embedded for self-containment)
All secrets needed to operate this skill. Used by helper scripts and direct Worker calls.
Cloudflare account
Account ID: 081ddb85cb399ad62a70210328d744fc
Account name: Das Experten Enterprise
API tokens (use CF_CLOUD_MASTER as default; switch to CF_WORKERS_EDIT for Worker-only deploys):
CF_CLOUD_MASTER: cfut_yk9DdlaSeE9KUvEIfJp9X7h0rT3FCYP9nu46fgB1c43012df
CF_WORKERS_EDIT: cfut_Qbjirmjg6FfzNxouHPJywEOCshtvsHvzze0926L9b9bf09f5
CF_FULL_INFRA: cfut_YUpmI2sdgIlC5s7QAb5Tff1fZt82gDJj3AetS3ojf6882ffa
CF_D1_ADMIN: cfut_yZaSSQe6RG1AVpodpbAacwIyVfuHy6apyyrzBoau329ab619
imager-bridge Worker
Worker name: imager-bridge
Worker URL: https://imager-bridge.dasexperten.workers.dev
Auth header: Authorization: Bearer Yhe2vdRXKXF_VUF-CgkgO-nd5CxkM7FsqqOaF2aU0X0
(this is BRIDGE_SECRET — bound as encrypted Worker secret)
Image generation APIs (bound to Worker as encrypted secrets)
GEMINI_API_KEY (free tier, das-experten-imager project):
AIzaSyDz2sE_CnFxePhi1cRPmnu1cdmkmR1bFeM
Model: gemini-2.5-flash-image
Limits: 500 RPD, 10 RPM, 250K TPM, resets midnight Pacific
Plan: free (no billing); prompts may be used by Google for training
OPENAI_API_KEY (paid):
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
Model: gpt-image-1
Use for: Cyrillic/non-Latin text-on-image where overlay isn't enough
ANTHROPIC_API_KEY (for in-Worker LLM if ever needed):
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
R2 storage
Bucket name: dasexperten-images
Bucket ID: 1d1b12958f2d4ea380276bd8d0a1ff02
Public URL base: https://pub-1d1b12958f2d4ea380276bd8d0a1ff02.r2.dev
Folder structure inside bucket:
banners/<campaign>/<timestamp>_<sku>_<rand>.png — generated banners
slides/<deck>/ — slides
cards/<marketplace>/<sku>/ — marketplace cards
mockups/<campaign>/ — design mockups
backgrounds/<campaign>/ — textless backgrounds
refs/products/<SKU-slug>/<DE###_filename>.png — product reference library
refs/characters/<Name>.png — character reference library
refs/styles/brand-logos/ — Das Experten logos
refs/styles/brand-badges/ — microbiome friendly etc.
refs/styles/skills-archive/ — backup of skill files
Lifecycle: banners/adhoc/* purged after 30 days; everything else permanent.
GitHub (for backup of Worker source)
GitHub PAT: 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
GitHub repo: https://github.com/dasexperten/imager-bridge (private)
Bootstrap — recreate /home/claude/.env
If .env is missing in a new Claude session, run this once:
cat > /home/claude/.env << 'EOF'
export GITHUB_PAT="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"
export CF_ACCOUNT_ID="081ddb85cb399ad62a70210328d744fc"
export CF_CLOUD_MASTER="cfut_yk9DdlaSeE9KUvEIfJp9X7h0rT3FCYP9nu46fgB1c43012df"
export CF_WORKERS_EDIT="cfut_Qbjirmjg6FfzNxouHPJywEOCshtvsHvzze0926L9b9bf09f5"
export CF_FULL_INFRA="cfut_YUpmI2sdgIlC5s7QAb5Tff1fZt82gDJj3AetS3ojf6882ffa"
export CF_D1_ADMIN="cfut_yZaSSQe6RG1AVpodpbAacwIyVfuHy6apyyrzBoau329ab619"
export BRIDGE_SECRET="Yhe2vdRXKXF_VUF-CgkgO-nd5CxkM7FsqqOaF2aU0X0"
export R2_PUBLIC_URL="https://pub-1d1b12958f2d4ea380276bd8d0a1ff02.r2.dev"
export R2_BUCKET="dasexperten-images"
EOF
chmod 600 /home/claude/.env
echo ". /home/claude/.env" >> /home/claude/.bashrc
2. WORKER — full source (embedded)
The imager-bridge Cloudflare Worker source. This is the single source of truth — if Worker source on Cloudflare diverges from what's below, redeploy from this file using the deploy procedure in Section 9.
2.1 metadata.json
{
"main_module": "worker.js",
"compatibility_date": "2025-09-01",
"bindings": [
{ "type": "r2_bucket", "name": "R2_BUCKET", "bucket_name": "dasexperten-images" },
{ "type": "plain_text", "name": "R2_PUBLIC_BASE", "text": "https://pub-1d1b12958f2d4ea380276bd8d0a1ff02.r2.dev" }
],
"keep_bindings": ["secret_text"]
}
2.2 worker.js (full source — 834 lines, Phase 2.8)
const GEMINI_FLASH_MODEL = 'gemini-2.5-flash-image';
const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
const IMAGE_TYPES = new Set([
'banner',
'slide',
'card',
'mockup',
'background',
'adhoc'
]);
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method === && url. === ) {
({
: ,
: ,
: ,
: ,
: ,
: ,
: {
: ,
: ,
: ,
: ,
:
},
: .(),
: [, , , ],
: env. ||
});
}
authHeader = request..();
(!authHeader || authHeader !== ) {
({ : }, );
}
(request. === && url. === ) {
(request, env);
}
(request. === && url. === ) {
(request, env);
}
(request. === && url. === ) {
(request, env);
}
(request. === && url. === ) {
(url, env);
}
(request. === && url. === ) {
(request, env);
}
(request. === && url. === ) {
(env);
}
(request. === && url. === ) {
(request, env);
}
(request. === && url. === ) {
(request, env);
}
(request. === && url. === ) {
(env);
}
({ : , : url. }, );
}
};
() {
payload;
{
payload = request.();
} (err) {
({ : , : err. }, );
}
{
prompt,
engine = ,
aspect_ratio = ,
image_type = ,
save_to_r2 = ,
r2_prefix,
reference_urls = [],
metadata = {}
} = payload;
(!prompt || prompt !== || prompt.(). === ) {
({ : , : }, );
}
(engine !== && engine !== && engine !== ) {
({
: ,
:
}, );
}
(!.(image_type)) {
({
: ,
:
}, );
}
(!.(reference_urls)) {
({ : , : }, );
}
(reference_urls. > ) {
({ : , : }, );
}
startedAt = .();
{
refImages = [];
( url reference_urls) {
resp = (url);
(!resp.) {
();
}
arrayBuf = resp.();
mimeType = resp..() || ;
refImages.({
: mimeType.()[].(),
: ( (arrayBuf))
});
}
actualEngine = engine === ? : engine;
result;
(actualEngine === ) {
result = (env., prompt, aspect_ratio);
} {
result = (env., prompt, aspect_ratio, refImages);
actualEngine = ;
}
elapsed = .() - startedAt;
(!save_to_r2) {
({
: ,
: actualEngine,
: actualEngine === ? : ,
: result.,
: result.,
: actualEngine === ? : ,
: elapsed,
: reference_urls.,
metadata
});
}
key = ({
image_type,
: r2_prefix,
: metadata.,
: metadata.,
: metadata.,
: metadata.,
: result.
});
imageBytes = (result.);
env..(key, imageBytes, {
: { : result. },
: {
: prompt.(, ),
: actualEngine,
: actualEngine === ? : ,
image_type,
: (metadata. || ),
: (metadata. || ),
: (metadata. || ),
: (reference_urls.),
: ().()
}
});
({
: ,
: actualEngine,
: actualEngine === ? : ,
: ,
: key,
: result.,
: actualEngine === ? : ,
: elapsed,
: reference_urls.,
metadata
});
} (err) {
({
: ,
: err.,
: engine
}, );
}
}
() {
contentType = request..() || ;
kind, slug, filename, bytes, mimeType;
{
(contentType.()) {
payload = request.();
kind = payload.;
slug = payload.;
filename = payload.;
mimeType = payload. || ;
(!payload.) {
({ : }, );
}
bytes = (payload.);
} (contentType.()) {
form = request.();
kind = form.();
slug = form.();
filename = form.();
file = form.();
(!file) ({ : }, );
mimeType = file. || ;
bytes = ( file.());
(!filename) filename = file.;
} {
({ : , : contentType }, );
}
} (err) {
({ : , : err. }, );
}
(!kind || ![, , , ].(kind)) {
({ : , : }, );
}
(!slug && kind !== ) {
({ : , : }, );
}
(!filename) ({ : }, );
key = slug
?
: ;
env..(key, bytes, {
: { : mimeType },
: {
kind,
slug,
: ().()
}
});
({
: ,
: ,
: key,
: bytes.,
: mimeType
});
}
() {
url = (request.);
kind = url..();
slug = url..() || ;
filename = url..();
mimeType = url..() || ;
(!kind || ![, , , ].(kind)) {
({ : }, );
}
(!filename) ({ : }, );
(!slug && kind !== ) {
({ : , : }, );
}
key = slug
?
: ;
env..(key, request., {
: { : mimeType },
: { kind, slug, filename, : ().() }
});
({
: ,
: ,
: key,
: mimeType
});
}
() {
kind = url..();
slug = url..() || ;
(!kind || ![, , , ].(kind)) {
({ : }, );
}
prefix = slug ? : ;
list = env..({ prefix, : });
files = list..( ({
: o.,
: o.,
: o.,
:
}));
({
: ,
kind,
slug,
prefix,
: files.,
files
});
}
() {
list = env..({ : , : });
files = list..( ({
: o.,
: o.,
:
}));
({
: ,
: files.,
files,
: list. ||
});
}
() {
body;
{
body = request.();
} (err) {
({ : , : err. }, );
}
{ sku, scene_type, character, max_refs = } = body;
(!sku) ({ : }, );
skuPrefix = ;
skuList = env..({ : skuPrefix, : });
(skuList.. === ) {
({
: ,
sku,
:
}, );
}
slug = skuList.[]..()[];
allFiles = skuList..( ({
: o.,
: o..().(),
:
}));
= () => {
f = filename.();
score = ;
(f.() && !f.() && !f.()) score += ;
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
isBrushSku = sku && .(sku.());
(isBrushSku) {
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
}
(scene_type === || scene_type === ) {
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
(f.()) score -= ;
(f.()) score -= ;
}
(scene_type === ) {
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
}
(scene_type === ) {
(f.()) score += ;
(f.()) score += ;
(f.()) score += ;
(f.()) score -= ;
}
(scene_type === || scene_type === ) {
(f.()) score += ;
(f.()) score += ;
}
(f.()) score -= ;
(f.()) score -= ;
(f.()) score -= ;
score;
};
scored = allFiles
.( ({ ...f, : (f.) }))
.( b. - a.);
productSlots = character ? max_refs - : max_refs;
picked = scored.(, productSlots);
(character) {
charKey = ;
charObj = env..(charKey);
(charObj) {
picked.({
: charKey,
: ,
: ,
: ,
:
});
} {
charKeyJpg = ;
charObjJpg = env..(charKeyJpg);
(charObjJpg) {
picked.({
: charKeyJpg,
: ,
: ,
: ,
:
});
}
}
}
({
: ,
: sku.(),
slug,
: scene_type || ,
: character || ,
: picked.( p.),
: picked.( ({ : p., : p., : p. || })),
: scored.
});
}
() {
{ from_key, to_key } = request.();
(!from_key || !to_key) {
({ : }, );
}
src = env..(from_key);
(!src) {
({ : , from_key }, );
}
env..(to_key, src., {
: src.,
: src.
});
({
: ,
from_key,
to_key,
:
});
}
() {
{ key } = request.();
(!key) ({ : }, );
env..(key);
({ : , : key });
}
() {
= ;
bytes = ();
key = ;
env..(key, bytes, {
: { : },
: { : }
});
({
: ,
: ,
: key,
: bytes.
});
}
() {
ext = mimeType === ? : ;
ts = ();
rand = .().().(, );
(prefix) {
base = sku ? : ;
;
}
skuPart = sku ? : ;
filename = ;
(image_type) {
:
;
:
;
:
;
:
;
:
;
:
:
;
}
}
() {
d = ();
= () => (n).(, );
(
d.().() +
(d.() + ) +
(d.()) +
+
(d.()) +
(d.()) +
(d.())
);
}
() {
(s)
.()
.(, )
.(, )
.(, );
}
() {
binary = (b64);
bytes = (binary.);
( i = ; i < binary.; i++) {
bytes[i] = binary.(i);
}
bytes;
}
() {
binary = ;
chunk = ;
( i = ; i < bytes.; i += chunk) {
binary += ..(, bytes.(i, i + chunk));
}
(binary);
}
() {
(!apiKey) ();
url = ;
parts = [];
( ref refImages) {
parts.({
: {
: ref.,
: ref.
}
});
}
parts.({ : prompt });
response = (url, {
: ,
: {
: ,
: apiKey
},
: .({
: [{ parts }],
: {
: [],
: { aspectRatio }
}
})
});
(!response.) {
errText = response.();
();
}
data = response.();
respParts = data?.?.[]?.?. || [];
imagePart = respParts.( p.?.);
(!imagePart) {
();
}
{
: imagePart..,
: imagePart.. ||
};
}
() {
(!apiKey) {
();
}
sizeMap = {
: ,
: ,
: ,
: ,
:
};
size = sizeMap[aspectRatio] || ;
response = (, {
: ,
: {
: ,
:
},
: .({
: ,
: prompt.(, ),
: ,
: size,
:
})
});
(!response.) {
errText = response.();
();
}
data = response.();
imageData = data?.?.[];
(!imageData) {
();
}
b64 = imageData. || imageData.;
(!b64) {
(imageData.) {
imgResp = (imageData.);
buf = imgResp.();
{
: ( (buf)),
:
};
}
();
}
{
: b64,
:
};
}
() {
(.(obj, , ), {
status,
: {
: ,
:
}
});
}
2.3 Worker endpoints
| Method | Path | Purpose | Auth |
|---|
| GET | / | Health check | none |
| POST | /generate | Main image gen — returns R2 URL | Bearer |
| POST | /upload-ref | Upload reference (JSON+base64 or multipart) | Bearer |
| GET | /list-refs?kind=&slug= | List refs by kind+slug | Bearer |
| GET | /list-all-refs | Full inventory (all 246 files) | Bearer |
| POST | /copy-ref | R2 copy (for rename ops) | Bearer |
| POST | /delete-ref | R2 delete | Bearer |
| POST | /resolve-refs | Smart auto-pick refs by SKU+scene+character | Bearer |
| POST | /test-r2 | R2 self-test | Bearer |
2.4 Worker secret bindings
These are bound to the Worker as encrypted secrets. To set or rotate:
. /home/claude/.env
echo -n "Yhe2vdRXKXF_VUF-CgkgO-nd5CxkM7FsqqOaF2aU0X0" | \
curl -X PUT \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/workers/scripts/imager-bridge/secrets" \
-H "Authorization: Bearer $CF_CLOUD_MASTER" \
-H "Content-Type: application/json" \
-d '{"name":"BRIDGE_SECRET","text":"Yhe2vdRXKXF_VUF-CgkgO-nd5CxkM7FsqqOaF2aU0X0","type":"secret_text"}'
curl -X PUT \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/workers/scripts/imager-bridge/secrets" \
-H "Authorization: Bearer $CF_CLOUD_MASTER" \
-H "Content-Type: application/json" \
-d '{"name":"GEMINI_API_KEY","text":"AIzaSyDz2sE_CnFxePhi1cRPmnu1cdmkmR1bFeM","type":"secret_text"}'
curl -X PUT \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/workers/scripts/imager-bridge/secrets" \
-H "Authorization: Bearer $CF_CLOUD_MASTER" \
-H "Content-Type: application/json" \
-d '{"name":"OPENAI_API_KEY","text":"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","type":"secret_text"}'
3. EXECUTION FLOW (default path — covers 90% of cases)
Step 1 — Resolve refs by SKU + scene + character
. /home/claude/.env
REFS=$(curl -s -A "Mozilla/5.0" -X POST \
"https://imager-bridge.dasexperten.workers.dev/resolve-refs" \
-H "Authorization: Bearer $BRIDGE_SECRET" \
-H "Content-Type: application/json" \
-d '{"sku":"DE105","scene_type":"brush_zoom","character":"Faeze","max_refs":4}')
echo "$REFS" | python3 -m json.tool
Response contains reference_urls[] ready to feed into /generate.
Step 2 — Build prompt (agency-grade, 4000–7000 chars)
The prompt is the most important part. Follow this structure:
═══ REFERENCE LOCKS ═══
Describe what reference images attached lock — geometry, colors, materials.
For products: brand DNA, label details, German flag, leaf badges, etc.
For characters: face structure, eye color, smile type, age, ethnicity.
═══ CONCEPT ═══
The creative idea that elevates the banner from stock to editorial.
Examples: "magnetic charcoal cloud", "before-after diptych", "anti-luxury industrial".
═══ COMPOSITION ═══
Specific layout — left/right thirds, character pose, product position.
Reserve negative space for text overlay if non-Latin language.
═══ GRIP LOCK (verbatim if character holds product) ═══
Use the exact grip block from bannerizer SKILL.md based on product type.
═══ LIGHTING ═══
Lancôme-style three-point: key + rim + minimal fill.
Color temperatures: 5600K cool key, 3200K warm rim.
═══ MATERIAL & TEXTURE PHYSICS ═══
Pore-level skin detail, translucent product materials, photorealistic textures.
═══ COLOR PALETTE ═══
Hex codes for each major surface.
═══ TYPOGRAPHY (only if Latin text — for non-Latin use Section 7 overlay) ═══
Position, hierarchy, font feeling, exact text.
═══ TECHNICAL ═══
Hasselblad H6D-100c, lens, f-stop, ISO. Cinema 5K render.
NO AI shimmer, NO smile clichés, NO stock-photo aesthetics.
Step 3 — Generate image
python3 << PYEOF
import json
REFS = json.loads('''$REFS''')
prompt = """[full agency-grade prompt from Step 2]"""
payload = {
"prompt": prompt,
"engine": "gemini-flash",
"aspect_ratio": "4:3",
"image_type": "banner",
"save_to_r2": True,
"reference_urls": REFS["reference_urls"],
"metadata": {
"sku": "DE105",
"campaign": "campaign_slug_here",
"skill_caller": "imager",
"scene_type": "brush_zoom",
"character": "Faeze"
}
}
with open("/tmp/imager_payload.json", "w") as f:
json.dump(payload, f)
PYEOF
curl -s --max-time 180 -A "Mozilla/5.0" -X POST \
"https://imager-bridge.dasexperten.workers.dev/generate" \
-H "Authorization: Bearer $BRIDGE_SECRET" \
-H "Content-Type: application/json" \
-d @/tmp/imager_payload.json \
-o /tmp/imager_result.json
python3 -c "
import json
d = json.load(open('/tmp/imager_result.json'))
print('status :', d.get('status'))
print('image_url:', d.get('image_url'))
print('time :', d.get('generation_time_ms'), 'ms')
"
Generation takes 14–20 seconds typical. Use --max-time 180 to allow for slow Gemini responses.
Step 4 — Download & verify
URL=$(python3 -c "import json; print(json.load(open('/tmp/imager_result.json'))['image_url'])")
curl -s -o /mnt/user-data/outputs/result.png "$URL"
file /mnt/user-data/outputs/result.png
If the result has Cyrillic/Arabic/CJK text on it AND the engine was gemini-flash — go to Section 7 (text overlay). Otherwise present the file to the user.
4. INPUT PARAMETERS (full schema)
When called as gate, calling skill provides this. When called directly, build from conversation.
prompt: string
image_type: enum
aspect_ratio: enum
campaign: string
sku: string | null
scene_type: enum | null
character: string | null
max_refs: int
engine: enum
save_to_r2: boolean
metadata: object
text_overlay: object
5. SCENE TYPES & SCORING LOGIC
The Worker /resolve-refs endpoint scores files by filename keywords against scene_type.
Default 6 scene types
| scene_type | Best-fit refs | Use case |
|---|
hero_shot | tube_and_box_dark, tube_dark, box_lying_dark | Solo product, dark BG, drama |
b2b_presentation | Same as hero_shot, slightly more formal | Investor/distributor decks |
lifestyle | lifestyle_country_*, paste_squeeze, paste_on_brush | Country-themed, real environment |
marketplace_card | tube_white, tube_and_box_white, box_lying | Ozon/WB/Amazon clean BG |
paste_on_brush | paste_on_brush.png + tube_dark | Demo "how to use" |
brush_zoom | brush_zoom_dark, brush_diagonal_dark, brush_head | Macro brush, charcoal mood |
Scoring function (excerpt from worker.js)
const scoreFile = (filename) => {
const f = filename.toLowerCase();
let score = 0;
if (f.includes('tube_and_box_dark') && !f.includes('_v2')) score += 5;
if (f.includes('tube_dark')) score += 4;
if (f.includes('tube_white')) score += 3;
if (f.includes('box_lying')) score += 2;
const isBrushSku = sku && /^DE(101|105|106|107|116|118|119|120|122|123|130)$/.test(sku);
if (isBrushSku) {
if (f.includes('brush_zoom_dark')) score += 9;
if (f.includes('brush_diagonal_dark')) score += 8;
if (f.includes('brush_head')) score += 7;
if (f.includes('brush_vertical')) score += 6;
if (f.includes('multipack_packaged')) score += ;
}
(scene_type === || scene_type === ) {
(f.()) score += ;
(f.()) score -= ;
}
(scene_type === ) {
(f.()) score += ;
(f.()) score += ;
}
(scene_type === ) {
(f.()) score += ;
(f.()) score -= ;
}
(scene_type === ) {
(f.()) score += ;
(f.()) score += ;
}
(f.()) score -= ;
(f.()) score -= ;
score;
};
6. REFERENCE LIBRARY — full inventory
R2 contains 246 reference files: 160 product photos + 81 character portraits + 5 brand assets.
6.1 Products (160 files across 26 SKUs)
All product files use the naming convention <SKU>_<descriptor>.<ext> and live at refs/products/<SKU-slug>/.
| SKU | Slug | Files | Category |
|---|
| DE101 | DE101-toothbrush | 1 | Brush |
| DE105 | DE105-schwarz-brush | 8 | Brush (charcoal) |
| DE106 | DE106-sensitiv-brush | 2 | Brush (sensitive) |
| DE107 | DE107-mittel-brush | 5 | Brush (medium) |
| DE111 | DE111-floss-waxed-mint | 4 | Floss |
| DE112 | DE112-floss-expanding | 5 | Floss |
| DE115 | DE115-floss-schwarz | 4 | Floss (charcoal) |
| DE116 | DE116-kraft-brush | 8 | Brush (heavy-duty) |
| DE118 | DE118-kinder-brush | 1 | Brush (kids — dolphin) |
| DE119 | DE119-grosse-brush | 2 | Brush (premium gold/silver) |
| DE120 | DE120-nano-brush | 7 | Brush (nano massage) |
| DE122 | DE122-aktiv-brush | 4 | Brush (purple aktiv) |
| DE123 | DE123-bio-brush | 5 | Brush (eco bamboo) |
| DE125 | DE125-interdental | 2 | Interdental |
| DE130 | DE130-intensiv-brush | 6 | Brush (intensive green) |
| DE201 | DE201-schwarz | 13 | Paste (charcoal) |
| DE202 | DE202-detox | 12 | Paste (detox) |
| DE203 | DE203-ginger-force | 15 | Paste (ginger) |
| DE204 | DE204-aktiv-forte | 2 | Paste (aktiv forte) |
| DE205 | DE205-cococannabis | 15 | Paste (cocoa+hemp) |
| DE206 | DE206-symbios | 10 | Paste (probiotic) |
| DE207 | DE207-buddy-microbies | 4 | Paste (kids microbies) |
|
6.2 File naming taxonomy (within each SKU folder)
DE###_tube_dark.png Solo tube, dark/black BG
DE###_tube_white.png Solo tube, white/clean BG
DE###_tube_and_box_dark.png Tube + box together, dark BG (HERO SHOT)
DE###_tube_and_box_white.png Tube + box together, white BG
DE###_tube_and_box_rus.png Russian-language packaging variant
DE###_tube_and_box_eng.png English-language packaging variant
DE###_box_dark.png Box only, vertical
DE###_box_lying_dark.png Box only, lying flat
DE###_box_vertical_dark.png Box only, standing vertical
DE###_box_horizontal_dark.png Box only, horizontal
DE###_lifestyle_<country>.png Country-themed lifestyle (thailand, ceilon, china, columbia, jungle, beach_sunrise)
DE###_lifestyle_paste_squeeze.png Paste squeezed out demo
DE###_paste_on_brush.png Paste squeezed onto brush head
DE###_brush_zoom_dark.png Macro brush head close-up
DE###_brush_diagonal_dark.png Brush at angle, dark BG
DE###_brush_vertical_*.png Brush standing vertical
DE###_multipack_blister_*.png 4-pack blister
DE###_multipack_packaged_*.png 4-pack in branded packaging
DE###_blister_single_*.png Single SKU blister
DE###_floss_container_*.png Floss container variations
6.3 Characters (81 files at refs/characters/)
Naming: <Name>.png for unique, <Name>_2.png, <Name>_3.png, <Name>_4.png for multi-photo people.
Single-photo: Abdulova, Alina, Andrea, ArminePapazjan, Ashley, Ayka,
Bodrova, Brucelda, Brucella, Chegga, Coli, Colina, Dalla,
Garrieta, Gerardina, Grishna, Haide, Hamda, Hardy, Harreth,
Helga, Henrietta, Hrista, Joanna, Kinsy, Kirienka, Kozlovskaya,
Krasochkina, Kristina, Kuravleva, Leona, Lewandowskaya, Lia,
Lota, Manuka, Medvedeva, Menuar, Michellanghela, Mironova,
MissKosmoss, MrsWaltz, Nagieva, Obnorskaya, Paola, Pevtsova,
Roberta, Romaria, Rona, Rubi, Rubina, Ruda, Ruslana, Shura,
Sollda, Tika, Toma, Tupa, Varda, Yakovleva, Zina
Multi-photo:
Aura, Aura_2 (2 photos)
Delona, Delona_2 (2 photos)
Faeze, Faeze_2 (2 photos)
Gozde, Gozde_2 (2 photos)
Klinsy, Klinsy_2, Klinsy_3 (3 photos)
Kumi, Kumi_2 (2 photos)
Marianna, Marianna_2, Marianna_3, Marianna_4 (4 photos)
Menshova, Menshova_2 (2 photos)
Ronalda, Ronalda_2 (2 photos)
Live-roster query (always returns current 81):
. /home/claude/.env
curl -s -A "Mozilla/5.0" \
"https://imager-bridge.dasexperten.workers.dev/list-refs?kind=characters&slug=" \
-H "Authorization: Bearer $BRIDGE_SECRET" \
| python3 -c "
import sys, json
d = json.load(sys.stdin)
for f in d.get('files', []):
print(' ', f['key'].split('/')[-1])
"
6.4 Brand assets
refs/styles/brand-logos/
logo_white_on_black.jpg Das Experten logo, white on black
logo_grey_on_black.png Das Experten logo, grey on black
logo_full_with_flag_white.png Full logo with German flag, white BG
refs/styles/brand-badges/
badge_microbiome_white.png "Microbiome friendly" badge, white BG
badge_microbiome_dark.png "Microbiome friendly" badge, dark BG
6.5 Live inventory query
To get current count and full file list at any time:
. /home/claude/.env
curl -s -A "Mozilla/5.0" \
"https://imager-bridge.dasexperten.workers.dev/list-all-refs" \
-H "Authorization: Bearer $BRIDGE_SECRET" \
| python3 -c "
import sys, json
d = json.load(sys.stdin)
print(f'Total refs: {d.get(\"count\")}')
"
7. TEXT OVERLAY (Cyrillic / non-Latin script handling)
The problem: Gemini Flash on free tier produces typos when rendering Cyrillic, Arabic, Vietnamese, Thai, CJK directly on banner. Examples observed: "ВОЗВРАВАЕТ" instead of "ВОЗВРАЩАЕТ", "СЕЙНАС" instead of "СЕЙЧАС", "Актививавнный" instead of "Активированный".
The solution: generate base banner with EMPTY negative space (Worker's prompt explicitly forbids text), then overlay perfect typography via Pillow using Cyrillic-perfect fonts.
7.0 PINNED HARD RULES — apply to every text overlay (user memory pin #30)
These rules are mandatory across ALL imager outputs — banners, slides, product cards, infographics, blog cards, decks. No exceptions.
-
Drop shadow on every text element (header, sub-header, description, CTA). Exact params: offset +4px right, +4px down, blur 1px, no background plate, no gradient strip underneath text.
-
Shadow CONTRASTS text color — this is non-negotiable. Never duplicate the text color in the shadow (e.g., black text + black shadow). Apply:
- Dark text (#16100C) → light cream shadow (#F5F0E5, alpha 230)
- Light text (#FAFAF8) → warm dark brown shadow (#2A1F18, alpha 230)
- The reference
compose_text.py resolves this automatically via shadow_color_for() based on text luminance.
-
Face-aware positioning — never cover a face. If a head occupies the top 30% of the frame (detected via dark-pixel mass in upper rows), the header moves to the bottom. Otherwise header stays top. Sub-header and description follow header position.
-
Letter-spacing = 1.0× natural always. No expanded tracking. No T H R E E style. If text overflows the frame width, reduce font size — never widen letter-spacing. The reference script's auto-fit loop reduces font size step-by-step to enforce this.
-
Default font = Manrope ExtraBold (variable weight 800). Auto-downloaded from Google Fonts on first use. Switch to Lora only for serif-magazine register; switch to DejaVu Sans Bold only if Manrope cannot be fetched.
These rules are enforced by compose_text.py below. Do not bypass them with ad-hoc Pillow code in a one-off script — always use this reference implementation.
7.1 Two-stage workflow
Stage 1 — Generate textless base. Add to your prompt the strict no-text policy:
═══ TEXT POLICY — STRICT ═══
ABSOLUTELY NO TEXT anywhere on the banner. NO headlines. NO captions. NO labels.
NO logos other than what's physically engraved on the product.
NO watermarks. NO numbers. NO Cyrillic, NO Latin, NO any script.
The banner is a pure visual — text will be added later in post-production.
Do not attempt to render any words.
Reserve PURE EMPTY DARK SPACE in the [lower-right OR right-third] of the image —
flat negative space, no particles, no detail, no rim light artifacts.
This area is reserved for text overlay.
Stage 2 — Pillow overlay using Manrope ExtraBold (default, modern editorial).
Default font is Manrope variable (weight 800 = ExtraBold). The script auto-downloads it from Google Fonts on first use into /home/claude/work/fonts/Manrope-Variable.ttf. Manrope covers Cyrillic, Latin Extended, Greek, Vietnamese — perfect for the international product line.
Fallback fonts already present on the Claude sandbox (use only if explicitly required):
/usr/share/fonts/truetype/google-fonts/Lora-Variable.ttf (serif, magazine register)
/usr/share/fonts/truetype/google-fonts/Lora-Italic-Variable.ttf (italic serif)
/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf (system fallback sans)
/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf (alternative serif)
/usr/share/fonts/truetype/freefont/FreeSerif.ttf (extensive Cyrillic)
/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf (Times-like serif)
Use Manrope ExtraBold as default for headers and sub-headers. Use Lora only when a serif-magazine register is explicitly required (perfumery, luxury, vintage). Use DejaVu Sans Bold only as a system fallback if Manrope cannot be fetched.
7.2 Reference Pillow overlay script (v2 — Drop Shadow + Face-Aware)
HARD RULES enforced by this script (from user memory pin #30):
-
Drop shadow on every text element (header, sub-header, description). Pillow drop shadow with these EXACT parameters: offset +4px right / +4px down, blur radius 1px, no underlying background plate, no semi-transparent gradient strip behind text.
-
Shadow CONTRASTS text color, never duplicates it.
- Dark text (#16100C) → light warm cream shadow #F5F0E5 alpha 230
- Light/white text (#FAFAF8) → dark warm brown shadow #2A1F18 alpha 230
- Never: dark text + dark shadow, or light text + light shadow. That just thickens the stroke and adds no separation from the background.
-
Face-aware positioning. Detect dark hair mass in the top 30% of the frame. If a head occupies that zone, header moves to the bottom; subhead/description follow underneath. Otherwise header stays top. Never cover the face.
-
Letter-spacing 1.0× natural always. No expanded tracking. If text overflows, reduce font size — never widen letter-spacing.
-
Font default = Manrope ExtraBold (variable weight 800). Modern editorial register, perfect Cyrillic/Latin/CJK coverage when downloaded from Google Fonts. Lora serif (legacy) is opt-in for magazine-style brand banners only — Manrope is the default for all blog, editorial, and social-card output.
Save as /home/claude/compose_text.py:
"""Pillow text overlay v2 — drop shadow + face-aware positioning.
Enforces user-pinned typography rules (memory pin #30).
Usage: python3 compose_text.py <base_image> <output_image> <payload.json>
"""
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import numpy as np
import json, sys, os, urllib.request
base_path = sys.argv[1]
output_path = sys.argv[2]
payload_path = sys.argv[3]
with open(payload_path) as f:
p = json.load(f)
base = Image.open(base_path).convert("RGB")
W, H = base.size
FONT_DIR = "/home/claude/work/fonts"
MANROPE = f"{FONT_DIR}/Manrope-Variable.ttf"
os.makedirs(FONT_DIR, exist_ok=True)
if not os.path.exists(MANROPE):
urllib.request.urlretrieve(
"https://github.com/google/fonts/raw/main/ofl/manrope/Manrope%5Bwght%5D.ttf",
MANROPE
)
def get_font(size, weight=800, path=None):
"""Return a Pillow font instance. Default Manrope ExtraBold."""
path = path or p.get("font_path", MANROPE)
f = ImageFont.truetype(path, size=size)
try:
f.set_variation_by_axes([weight])
except Exception:
pass
return f
def text_dims(text, font):
bbox = ImageDraw.Draw(Image.new('RGB', (1,1))).textbbox((,), text, font=font)
bbox[] - bbox[], bbox[] - bbox[]
():
r, g, b = rgba[:]
(*r + *g + *b) <
():
is_dark(text_color):
(, , , )
(, , , )
():
W, H = img_rgba.size
shadow_layer = Image.new(, (W, H), (, , , ))
ImageDraw.Draw(shadow_layer).text(
(xy[] + offset[], xy[] + offset[]),
text, font=font, fill=shadow_color_for(fill)
)
blur > :
shadow_layer = shadow_layer.(ImageFilter.GaussianBlur(blur))
text_layer = Image.new(, (W, H), (, , , ))
ImageDraw.Draw(text_layer).text(xy, text, font=font, fill=fill)
Image.alpha_composite(
Image.alpha_composite(img_rgba, shadow_layer),
text_layer
)
():
arr = np.array(img_rgb.convert())
row (arr.shape[]):
(arr[row, :] < dark_threshold).() > min_count:
row
():
W, H = img_rgb.size
head_top = detect_head_top(img_rgb)
head_top head_top < (H * ):
(H - text_h - (H * ) - , )
((H * ), )
result = base.convert()
margin_x = (W * p.get(, ))
p.get():
header_text = p[]
header_size = p.get(, )
text_color = (p.get(, [, , , ]))
font = get_font(header_size)
header_size > :
w, _ = text_dims(header_text, font)
w <= W - * margin_x:
header_size -=
font = get_font(header_size)
_, text_h = text_dims(header_text, font)
text_y, strategy = pick_header_position(base, text_h, margin_x)
text_x = margin_x
result = draw_text_with_shadow(result, (text_x, text_y), header_text, font, text_color)
p[] = text_y
p[] = strategy
p[] = text_h
p.get():
sub_text = p[]
sub_size = p.get(, )
sub_color = (p.get(, [, , , ]))
font = get_font(sub_size, weight=p.get(, ))
sub_size > :
w, _ = text_dims(sub_text, font)
w <= W - * margin_x:
sub_size -=
font = get_font(sub_size, weight=p.get(, ))
_, sub_h = text_dims(sub_text, font)
sub_y = p.get(, (H * )) + p.get(, ) +
result = draw_text_with_shadow(result, (margin_x, sub_y), sub_text, font, sub_color)
p.get():
desc_text = p[]
desc_size = p.get(, )
desc_color = (p.get(, [, , , ]))
font = get_font(desc_size, weight=p.get(, ))
desc_y = p.get(, (H * )) + p.get(, ) +
result = draw_text_with_shadow(result, (margin_x, desc_y), desc_text, font, desc_color)
p.get():
cta_text = p[]
cta_size = p.get(, )
cta_text_color = (p.get(, [, , , ]))
cta_bg = (p.get(, [, , , ]))
pad_x, pad_y = ,
font = get_font(cta_size, weight=)
text_w, text_h = text_dims(cta_text, font)
cta_w = text_w + * pad_x
cta_h = text_h + * pad_y
cta_x = W - margin_x - cta_w
cta_y = H - (H * ) - cta_h
cta_layer = Image.new(, (W, H), (, , , ))
ImageDraw.Draw(cta_layer).rectangle(
[(cta_x, cta_y), (cta_x + cta_w, cta_y + cta_h)], fill=cta_bg
)
result = Image.alpha_composite(result, cta_layer)
result = draw_text_with_shadow(
result, (cta_x + pad_x, cta_y + pad_y - ),
cta_text, font, cta_text_color
)
result.convert().save(output_path, , optimize=)
()
()
7.3 Payload format
Minimal payload — just a header on a dark scene:
{
"header": "Что вы перестали делать, не заметив этого",
"header_size": 64,
"header_color": [22, 16, 12, 255]
}
Full payload with sub-header, description, and CTA:
{
"header": "Чёрное возвращает черноту",
"header_size": 72,
"header_color": [22, 16, 12, 255],
"subheader": "DAS EXPERTEN · SCHWARZ",
"subheader_size": 28,
"subheader_weight": 700,
"subheader_color": [22, 16, 12, 255],
"description": "Активированный уголь из кокоса. Налёт уходит с первой чистки.",
"desc_size": 22,
"desc_weight": 500,
"desc_color"
Color rule reminder:
- For a light-scene image with dark text: keep header_color around
[22,16,12,255]. Shadow auto-resolves to cream [245,240,229,230].
- For a dark-scene image (night, deep shadow, dark fabric backgrounds): set header_color to
[250,250,248,255]. Shadow auto-resolves to warm dark brown [42,31,24,230].
- The script's
shadow_color_for() makes this decision automatically based on text luminance — you only set text color.
Font weight values (Manrope variable axis):
- 200 ExtraLight, 300 Light, 400 Regular, 500 Medium, 600 SemiBold, 700 Bold, 800 ExtraBold (default for headers)
7.4 Usage
python3 /home/claude/compose_text.py \
/tmp/textless_base.png \
/mnt/user-data/outputs/final_banner.png \
/tmp/text_payload.json
7.5 Layout variants
The default Pillow script puts text in lower-right. Modify headline_y_pct and use mirrored x calculations for other layouts:
| Layout | headline_y_pct | x calculation | Where to reserve negative space in prompt |
|---|
| Lower-right (default) | 0.50 | W - RIGHT_MARGIN - line_w | "lower-right quadrant" |
| Upper-right | 0.05 | same | "upper-right quadrant" |
| Lower-left | 0.50 | LEFT_MARGIN | "lower-left quadrant" |
| Centered | 0.40 | (W - line_w) / 2 | "center, around mid-frame" |
7.6 Infographic-specific overlay pipeline (CRITICAL — for technolog skill)
Why this exists: Infographics are NOT banners. A banner has 1 headline + 1 sub + 1 CTA in known locations. An infographic has 6–8 callout cards scattered around a hero product, each with its own headline, value, and label. The default Pillow overlay (Sections 7.2–7.5) is banner-shaped and fails on infographics — you'll get either AI-mangled Cyrillic embedded inside Gemini's render, or a single text block in the corner ignoring all the callout cards.
Symptom of incorrect infographic generation: Gemini's output contains gibberish-Cyrillic text like "ПОПЛОЛИЧЕРЕ" or "АОТАВИВНТЫЙ" — this means the skill let Gemini render text inside callout cards, instead of producing textless cards and overlaying with Pillow.
Mandatory infographic rule: when image_type=infographic OR scene_type=infographic OR caller is technolog skill OR prompt contains "callout" / "infographic" / "fact panel" / "клинический инфографик":
Stage 1 — Gemini renders ONLY:
- Hero product (toothbrush, paste tube, box, etc.) — full reference fidelity
- Callout card frames (rectangular borders, gold or grey strokes)
- Decorative micro-icons INSIDE cards (molecular schematics, geometric diagrams, anatomical cross-sections — NEVER with text labels)
- Background gradient and atmospheric layer
- NO text anywhere — neither headlines, nor values, nor labels, nor unit suffixes (%, ppm, mm). Reserve clean negative space inside each card for Pillow.
Prompt MUST include this strict NO-TEXT clause:
TEXT POLICY — STRICT: ABSOLUTELY NO TEXT, NO numbers, NO labels, NO percentages,
NO units (mm/ppm/%/Ra), NO before/after captions, NO Cyrillic, NO Latin, NO any
script. Every callout card has a clean blank rectangle reserved at its top
(headline zone) and a clean blank strip at its bottom (value/label zone).
The decorative diagram inside each card is purely visual — no text labels.
Text will be added in post-production via Pillow overlay.
Stage 2 — Pillow renders all text in known card geometry
Caller (technolog skill) must pass a structured layout payload, not just a prompt. Format:
{
"layout": "infographic_8panel_centered_product",
"canvas": [864, 1152],
"hero_product_bbox": [305, 240, 555, 750],
"panels": [
{
"id": "panel_1_top_left",
"bbox": [40, 50, 395, 380],
"headline": "УГОЛЬ В ПОЛИМЕРЕ",
"headline_font": "russo",
"headline_size": 28,
"description_lines":
The Pillow overlay script for infographics walks panels[] and for each panel renders:
headline at top of bbox (font from font system, see Section 4 — typography)
description_lines below headline, smaller body font
value (large, bold, gold or accent color) anchored at bottom-third of bbox
value_label below value, small caption font
Reference implementation — compose_infographic.py
"""Pillow overlay for infographic-style multi-panel images.
Caller provides layout JSON; this script walks panels and renders all text."""
from PIL import Image, ImageDraw, ImageFont
import json, sys
base_path, output_path, layout_path = sys.argv[1:4]
with open(layout_path) as f:
layout = json.load(f)
base = Image.open(base_path).convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
FONTS = {
"russo": "/home/claude/fonts/RussoOne-Regular.ttf",
"rubik": "/home/claude/fonts/Rubik-Variable.ttf",
"manrope": "/home/claude/fonts/Manrope-Variable.ttf",
"mulish": "/home/claude/fonts/Mulish-Variable.ttf",
"comfortaa": "/home/claude/fonts/Comfortaa-Bold.ttf",
}
def get_font(role, size):
return ImageFont.truetype(FONTS.get(role, FONTS["comfortaa"]), size)
GOLD = (188, 152, 78, 255)
WHITE = (245, 240, 232, 255)
BODY = (200, 200, 210, 255)
for panel in layout["panels"]:
x1, y1, x2, y2 = panel[]
panel_w = x2 - x1
pad =
panel:
font = get_font(panel.get(, ), panel.get(, ))
draw.text((x1 + pad, y1 + pad), panel[], font=font, fill=WHITE)
panel:
font = get_font(panel.get(, ), panel.get(, ))
line_h = panel.get(, ) +
desc_y = y1 + pad + panel.get(, ) +
i, line (panel[]):
draw.text((x1 + pad, desc_y + i * line_h), line, font=font, fill=BODY)
panel:
font = get_font(panel.get(, ), panel.get(, ))
bbox = draw.textbbox((, ), panel[], font=font)
v_w = bbox[] - bbox[]
v_x = x1 + (panel_w - v_w) //
v_y = y2 -
draw.text((v_x, v_y), panel[], font=font, fill=GOLD)
panel:
font = get_font(panel.get(, ), panel.get(, ))
bbox = draw.textbbox((, ), panel[], font=font)
l_w = bbox[] - bbox[]
l_x = x1 + (panel_w - l_w) //
draw.text((l_x, y2 - ), panel[], font=font, fill=BODY)
result = Image.alpha_composite(base, overlay).convert()
result.save(output_path, , optimize=, quality=)
()
Diagnostic checklist — when infographic comes back wrong
If user reports "Cyrillic gibberish" or "wrong text" or "garbled labels" on an infographic:
- Check Gemini prompt — did it contain TEXT POLICY STRICT clause? If no → Pillow was bypassed.
- Check whether Pillow ran — did the call sequence include
compose_infographic.py? If no → only Gemini ran, that's the bug.
- Check layout JSON — does the panel
bbox array match the cards Gemini drew? If misaligned → Pillow text lands in wrong places, looks broken.
- Re-fire with strict NO-TEXT prompt + Pillow overlay. Do NOT propose switching to OpenAI as the fix — Pillow with correct layout JSON solves this regardless of language complexity.
Caller responsibility (technolog, das-presenter, etc.)
Skills that produce infographics MUST:
- Compose the layout JSON (panel positions, headlines, values) in their own logic
- Pass
layout=... parameter alongside the textless prompt to imager
- Imager validates layout JSON before generation