Claude Cursor GitHub Copilot opencode Skill

media-orchestration

Section-by-section media planning and generation. Image generation (GPT Image 1.5 primary, built-in fallback), logo/icon generation (Ideogram v3 → favicon set), video generation (Sora), social preview images (OG 1200x630 + AI search optimization), stock photo curation (Pexels, Pi

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download heymegabyte-claude-skills-12-media-orchestration-e7acb91.zip · 69 KB
Part of heymegabyte/claude-skills — 18 skills

Install

skills CLI npx skills add https://github.com/heymegabyte/claude-skills/tree/master/12-media-orchestration
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install heymegabyte-claude-skills@llmmart
Git git clone https://github.com/heymegabyte/claude-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole heymegabyte/claude-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

12 — Media Orchestration

Plan and generate all site media section-by-section: images (GPT Image 1.5), logos (Ideogram v3), video (Sora), OG cards, and compression pipeline.

Model migration note (pass-77, 2026-06-09): DALL-E → GPT Image 1.5 + GPT-4o → GPT Image 2 vision. Per platform.openai.com/docs/deprecations.

Submodules

  • media-prompts — prompt templates, Ideogram v3 API
  • compression-pipeline — Python code, format tables, CF Image Transforms, CLS, broken image detection
  • og-image-generation — Satori edge-rendered OG, KV / R2 cache, meta-tag helper
  • image-optimization — Sharp processing, responsive srcset, WebP/AVIF, blur placeholders, R2 pipeline
  • image-profiling — GPT Image 2 vision batch profiling
  • lightbox-classifier — per-image eligibility: kind!=logo + ≥1024×768 + score≥7
  • social-brand-hex — canonical brand-color map per social platform
  • notebooklm-pipeline — per-site podcast via ElevenLabs Studio + infographic via Vega-Lite/Recraft/GPT-Image-2 + HeyGen video + CF Stream + RSS + JSON-LD + cost ceiling $3.50/site

Strategy by Section

Hero → GPT Image 1.5 / Sora · Features → GPT Image 1.5 / SVG · How It Works → GPT Image 1.5 · Testimonials → stock · About → stock/real · Blog → GPT Image 1.5 · Social → Satori OG 1200×630 · Icons → Ideogram v3

Pre-gen checklist: communication goal? Brand style? Dimensions? Format? Budget? Stock or generated?

Visual Inspection (MANDATORY)

Read every image before deploy. Check: blur, artifacts, watermarks, wrong colors, AI hallucinations, gibberish text. Fail = regenerate w/ improved prompt. Quality bar: 2× retina, no artifacts, brand palette, consistent style, no uncanny valley.

Brian's Style

  • Space/cosmic — #00E5FF + #7C3AED, deep black (#060610); connections/dots — quantum, neural, constellation
  • "Ultra realistic" scenes; transparent logos; simpler always; motifs — squirrels, turtles

Image Generation

  • GPT Image 1.5 preferred (best quality); GPT Image 1 for speed; GPT Image 1-mini for bulk/drafts
  • Fallback: scripts/image_gen.py; product screenshots: Playwright on live URL
  • Be specific: include colors, specify avoidances

GPT Image 1.5 First Slot-Fill (CANONICAL — UNIVERSAL)

PRIMARY originator for every slot real-entity sources (Places / uploads / scrape) didn't fill. GPT Image 1.5 invoked BEFORE generic stock; stock APIs run parallel speed-pass fallback (instant return if GPT Image 1.5 hangs >15s). See skill 15 media-acquisition + Fail-CLOSED auto-regenerate (5 attempts, $0.40 worst-case ceiling per slot).

Per-Slot Prompt Mandatory Fields (BUILD-BREAKING — validate-image-prompts.mjs + validate-dalle-slot-fill.mjs)

Every GPT Image 1.5 call MUST encode 6 fields from _media_slots.json:

  1. Page topic + intent verbatim from topic_intent
  2. Brand palette tokens from _brand.json.colors (inline hex)
  3. Composition + aspect ratio matching aspect
  4. Subject specificity (NEVER "people" — always "octogenarian volunteer plating soup, soft window light, documentary style")
  5. Photographic technical specs (camera, lens, lighting, DoF — "shot on Hasselblad, 85mm prime, golden hour, shallow DoF")
  6. Negative prompt block ("no text, no watermarks, no logos, no extra fingers, no AI artifacts, no stock-photo cliches")

Generic prompts FAIL validator. Same template applies to FLUX, Recraft, Stability.

Fail-CLOSED Auto-Regenerate (BUILD-BREAKING — validate-no-empty-slots.mjs)

Every slot MUST end build w/ filled_url != null AND filled_score >= relevance_floor (default 8/10 via GPT Image 2 vision). Failure modes (Pexels empty, NSFW-flagged, broken scrape, vision below floor) → immediate re-gen w/ REFINED prompt — NEVER silent skip, NEVER substitute brand-gradient unless 5 attempts exhausted. media_pipeline_orchestrator sub-agent owns this loop. See media-acquisition.md § Fail-CLOSED chain.

Logo / Icon / Video / OG

  • Logo — Ideogram v3 (best text rendering); Icons — Recraft V3; output: PNG transparent + SVG; bg removal → favicon set (16/32/180/192/512 + maskable); brand mark MUST be vector-clean
  • Video — Sora (primary cinematic); Veo (narrative stitching, 7-8 × 8-sec clips → 60-sec arc); HeyGen (explainer/spokesperson); captions VTT + transcript; prefers-reduced-motion → static poster fallback
  • OG (1200×630) — Satori edge-rendered, per-route unique, BRANDED CARD never raw photo, ≤100KB, cached KV 7d / R2 forever

Stock Photography + Asset Compression + Performance

Stock: Pexels first (free, API); Pixabay second. Never Unsplash (generic), iStock/Getty (paid). Critique-and-remix loop max 3 rounds; AI vision <7/10 = reject + regenerate.

Compression: AVIF primary (94% browser support, 20-30% smaller than WebP); WebP fallback (Safari 14+); JPEG legacy. Sharp: 320/640/1280/1920w srcset; blur placeholder; dominant color → CSS bg fill. R2 upload per-extension content-type.

Budgets: total images/page ≤500KB; largest single ≤200KB; Hero LCP fetchpriority="high" + preload; loading="lazy" + decoding="async" on all others.

See submodules for: media-prompts, compression-pipeline, og-image-generation, image-optimization, image-profiling, lightbox-classifier, social-brand-hex, notebooklm-pipeline, build-breaking-rules.

Files (claude-skills)
  • templates
    • PROMPTS.md 10 KB
      # Media Generation Prompt Templates
      
      ## 1. Hero Image Generation
      
      ### Dark Tech Hero
      
      ```
      Subject: Abstract geometric composition representing [product concept]
      Style: Dark digital illustration with subtle 3D depth
      Mood: Premium, futuristic, professional
      Color palette: Deep navy #060610 background, cyan #00E5FF accent lighting, blue #50AAE3 secondary glow
      Composition: Wide format, centered focal point with radiating geometric elements
      Background: Deep dark space with subtle grid or circuit pattern
      Lighting: Cyan edge lighting, volumetric glow effects, dark ambient
      Details: Clean geometric shapes, subtle particle effects, no text
      Avoid: Text, watermarks, people, busy patterns, bright backgrounds
      Aspect ratio: 1792x1024
      ```
      
      ### Product-Forward Hero
      
      ```
      Subject: [Product interface or concept] floating in dark space
      Style: 3D render with glass morphism elements
      Mood: Clean, modern, trustworthy
      Color palette: #060610 base, #00E5FF highlights, #50AAE3 reflections
      Composition: Central product mockup with subtle environmental elements
      Background: Dark gradient with subtle depth blur
      Lighting: Soft studio lighting with cyan accent rim light
      Details: Realistic materials, subtle reflections, clean edges
      Avoid: Text overlays, watermarks, generic stock feel
      Aspect ratio: 1792x1024
      ```
      
      ### Community/People Hero
      
      ```
      Subject: Diverse team of professionals [specific context from product]
      Style: Warm editorial photography feel, digitally enhanced
      Mood: Energetic, collaborative, inclusive
      Color palette: Warm tones with #00E5FF accent elements in environment
      Composition: Group in dynamic arrangement, environmental context
      Background: Modern workspace or relevant environment
      Lighting: Natural light with warm color temperature
      Details: Genuine expressions, professional attire, real environment
      Avoid: Staged corporate feel, stock photo clichés, all same ethnicity
      Aspect ratio: 1792x1024
      ```
      
      ---
      
      ## 2. Feature Illustration Generation
      
      ### Icon-Style Feature
      
      ```
      Subject: Minimalist icon representing [feature concept]
      Style: Flat design with subtle gradient and depth
      Mood: Clean, modern, immediately understandable
      Color palette: Cyan #00E5FF primary, Blue #50AAE3 secondary, on transparent
      Composition: Centered, single focal icon
      Background: Transparent or #060610
      Lighting: Flat with subtle gradient shading
      Details: 2-3 simple geometric elements, recognizable at 128px
      Avoid: Text, complex details, photorealism, more than 3 colors
      Aspect ratio: 1:1 (1024x1024)
      ```
      
      ### Detailed Feature Illustration
      
      ```
      Subject: [Feature concept] shown as an isometric scene
      Style: Isometric digital illustration, clean lines
      Mood: Explanatory, engaging, technical-but-approachable
      Color palette: #060610 darks, #00E5FF accents, #50AAE3 mid-tones, #f0f0f5 highlights
      Composition: Isometric perspective, single scene, clear focal point
      Background: Transparent or subtle dark gradient
      Lighting: Even, from top-left, subtle shadows
      Details: Clean geometry, consistent line weight, labeled elements
      Avoid: Photorealism, busy backgrounds, text within the illustration
      Aspect ratio: 4:3 (1024x768)
      ```
      
      ---
      
      ## 3. Brand-Faithful Website Rebuild Imagery
      
      ### Preserve Existing Brand
      
      ```
      Subject: [Describe the original image's content faithfully]
      Style: [Match original style — photograph, illustration, render]
      Mood: [Match original mood — professional, playful, serious]
      Color palette: [Extract from original brand — specific hex values]
      Composition: [Match original layout — centered, left-weighted, full-bleed]
      Background: [Match original background treatment]
      Lighting: [Match original lighting style]
      Details: [Preserve key brand elements, improve technical quality]
      Avoid: Changing the brand identity, inventing new visual language, text in images
      Aspect ratio: [Match original dimensions]
      ```
      
      ### Modernize While Preserving
      
      ```
      Subject: [Same subject as original, described specifically]
      Style: Modern digital [illustration/photograph/render] inspired by [describe original style]
      Mood: [Original mood] with elevated production quality
      Color palette: [Original brand colors + refined complementary colors]
      Composition: [Improved composition based on original intent]
      Background: [Modernized background maintaining brand character]
      Lighting: [Enhanced lighting maintaining original mood]
      Details: Higher resolution, sharper details, modern design sensibility
      Avoid: Losing brand recognition, drastic style changes, removing trust elements
      Aspect ratio: [Optimized for modern web — 16:9 or 3:2]
      ```
      
      ---
      
      ## 4. Social Preview Image (OG)
      
      ### Standard OG Image
      
      ```
      Subject: Brand-forward preview card for [product name]
      Style: Clean graphic design, bold typography feel
      Mood: Professional, inviting, clear
      Color palette: #060610 background, #f0f0f5 primary text area, #00E5FF accent
      Composition: Left-aligned text area with right-side brand mark or icon
      Background: Dark solid or subtle gradient
      Lighting: N/A (graphic design)
      Details: Large readable product name, short tagline, brand icon
      Avoid: Small text (unreadable at thumbnail), photographs, busy backgrounds
      Dimensions: 1200x630 pixels exactly
      ```
      
      ### Blog Post OG Image
      
      ```
      Subject: Visual representation of [article topic]
      Style: Editorial illustration or thematic graphic
      Mood: Informative, engaging, shareable
      Color palette: #060610 base with topic-appropriate accent from brand palette
      Composition: Simple, centered, with clear space for mental title overlay
      Background: Dark with subtle thematic elements
      Lighting: Dramatic if illustration, flat if graphic
      Details: One clear visual metaphor for the article's topic
      Avoid: Actual text in the image, complex scenes, generic stock feel
      Dimensions: 1200x630 pixels exactly
      ```
      
      ---
      
      ## 5. Short Hero Video Generation (Sora)
      
      ### Abstract Tech Background Loop
      
      ```
      [Opening, 0-2s]: Slowly rotating geometric crystalline structure glowing with cyan (#00E5FF) light against deep dark (#060610) background
      [Main action, 2-6s]: Structure gently morphs and pulses, particles drift outward in slow motion, light refracts through facets
      [Closing, 6-8s]: Return to initial state for seamless loop
      
      Style: Cinematic CGI, clean and minimal
      Camera: Slow orbit, 15 degrees total rotation
      Lighting: Volumetric cyan lighting with soft blue (#50AAE3) fill
      Color: Dark background, cyan and blue accents only
      Mood: Premium, futuristic, hypnotic
      ```
      
      ### Product Demo Intro
      
      ```
      [Opening, 0-3s]: Dark screen, product interface fades into view from center
      [Main action, 3-8s]: Interface elements animate in sequence — navigation appears, content loads, key feature activates
      [Closing, 8-10s]: Interface settles, subtle ambient glow around edges
      
      Style: Screen recording aesthetic, slightly enhanced with depth
      Camera: Static frontal, slight zoom-in during action
      Lighting: Screen glow illuminating dark environment
      Color: Interface colors matching brand palette
      Mood: Polished, professional, demonstrative
      ```
      
      ### Ambient Nature Loop (for non-tech products)
      
      ```
      [Opening, 0-2s]: Slow aerial view of [environment relevant to product]
      [Main action, 2-6s]: Gentle camera drift, natural movement (wind, water, light)
      [Closing, 6-8s]: Seamless return to opening framing
      
      Style: Cinematic drone footage aesthetic
      Camera: Slow drift, barely perceptible movement
      Lighting: Golden hour or soft overcast
      Color: Warm naturals with brand color grading
      Mood: Calm, aspirational, premium
      ```
      
      ---
      
      ## 6. Logo/Icon Generation (Ideogram)
      
      ### Tech Product Logo
      
      ```
      A modern, minimal logo for "[Product Name]", a [product type] tool.
      Style: DESIGN
      Clean geometric mark, suitable for dark backgrounds.
      Primary color: cyan (#00E5FF).
      No text in the icon version.
      Simple enough to work at 16x16 pixels.
      Professional, technical, distinctive.
      Not generic — avoid common symbols (gears, clouds, lightning bolts).
      ```
      
      ### App Icon (Square)
      
      ```
      A square app icon for "[Product Name]".
      Style: DESIGN
      Dark background (#060610).
      Simple centered icon mark in cyan (#00E5FF).
      Rounded corners (iOS style).
      No text.
      Recognizable at small sizes.
      Clean, bold, minimal.
      ```
      
      ### Horizontal Lockup
      
      ```
      A horizontal logo lockup for "[Product Name]".
      Style: DESIGN
      Icon on the left, "[Product Name]" text on the right.
      Font: clean geometric sans-serif similar to Space Grotesk.
      Icon in cyan (#00E5FF), text in white (#f0f0f5).
      Dark background (#060610) or transparent.
      Professional and modern.
      ```
      
      ---
      
      ## 7. Image Critique and Remix
      
      ### Critique Template
      
      ```
      Evaluate this image against these criteria (1-10 each):
      
      1. Communication: Does it convey [intended message]?
      2. Brand alignment: Does it match [brand colors/style/mood]?
      3. Composition: Is the layout strong and intentional?
      4. Technical quality: Is it sharp, well-lit, artifact-free?
      5. Scalability: Does it work at the intended display size?
      6. Context fit: Does it look good in a dark UI?
      7. Uniqueness: Does it avoid generic AI/stock feel?
      8. Premium feel: Would this appear in a top-tier product?
      
      Overall score: [average]
      Specific issues: [list]
      Remix recommendation: [specific changes to prompt]
      ```
      
      ### Remix Prompt Adjustment
      
      ```
      Original prompt: [paste]
      Issues identified: [from critique]
      Adjusted prompt: [modified to address issues]
      Changes made:
      - [Change 1]: addresses [issue 1]
      - [Change 2]: addresses [issue 2]
      Attempt: [N of 3 max]
      ```
      
      ---
      
      ## 8. Video Critique and Remix
      
      ### Video Critique Template
      
      ```
      Evaluate this video against these criteria (1-10 each):
      
      1. Motion quality: Smooth, natural, no jumps?
      2. Action clarity: Is the main action clear and purposeful?
      3. Duration: Appropriate for intended use?
      4. Style consistency: Matches brand and product tone?
      5. Loop quality: Seamless if intended as loop?
      6. Artifact check: No morphing, flickering, or distortion?
      7. Color grading: Matches brand palette?
      8. Audio: Appropriate (silent, ambient, musical)?
      
      Overall score: [average]
      Specific issues: [list]
      Remix recommendation: [specific changes to prompt or parameters]
      ```
      
      ### Video Remix Adjustment
      
      ```
      Original prompt: [paste]
      Issues identified: [from critique]
      Adjusted prompt: [modified to address issues]
      Parameter changes:
      - Duration: [if changing]
      - Model: [sora-2 vs sora-2-pro]
      - Size: [if changing]
      Attempt: [N of 3 max]
      ```
      
  • 30-ideogram-methods.md 15.8 KB
    ---
    name: "30 Ideogram Methods"
    description: "Thirty distinct, creative ways every generated site can consume Ideogram v3 assets — hero/illustration/OG/divider/parallax/card/stat/badge/role/era/404/confetti/newsletter/email/PDF/sticker/loading/easter-egg/light-dark/poster/lockup/PWA/favicon/cursor/watermark/press/social/ticker/scroll-glyph. Each method has prompt template, slot manifest contract, render component, fallback, and cache key. Goal: replace every stock image with brand-coherent generated asset + extend reach across PWA, social, print."
    updated: "2026-05-11"
    ---
    
    # 30 Ideogram Methods (***UNIVERSAL CATALOG — EVERY GENERATED SITE***)
    
    Brand-coherent Ideogram v3 assets fan out across the entire site beyond `hero.png`.
    
    Each method has: `slot id` · `dims` · `dpi` · `prompt template` · `negative prompt` · `style preset` · `format` · `output path` · `consumer component` · `fallback chain` · `cache key`
    
    Manifest lives at `src/data/ideogram/methods.ts` (typed catalog) + `public/_ideogram/manifest.json` (post-build asset registry).
    
    ### Pipeline
    
    `scripts/generate-ideogram-assets.mjs` reads catalog → renders missing slots via Ideogram v3 API → uploads to `public/images/ideogram/` (small assets) or R2 (>500KB) → writes manifest. Re-runs idempotent (skips slots with valid md5 in manifest).
    
    ## Slot Manifest Schema (canonical)
    
    ```ts
    export type IdeogramAspect = '1:1' | '1:3' | '3:1' | '3:2' | '2:3' | '16:9' | '9:16' | '16:10' | '10:16' | '4:3' | '3:4';
    export type IdeogramStyle = 'AUTO' | 'GENERAL' | 'REALISTIC' | 'DESIGN' | 'ANIME';
    export interface IdeogramSlot {
      id: string;                       // canonical slug e.g. "hero-home", "og-services"
      method: number;                   // 1-30 catalog ref
      prompt: string;                   // resolved (token-substituted) prompt
      negativePrompt?: string;
      aspect: IdeogramAspect;
      styleType: IdeogramStyle;
      renderingSpeed: 'TURBO' | 'DEFAULT' | 'QUALITY';
      magicPrompt?: 'ON' | 'OFF' | 'AUTO';
      numImages?: 1 | 2 | 3 | 4 | 8;
      seed?: number;
      outputPath: string;               // `/images/ideogram/<id>.{webp|png|svg}`
      outputFormat: 'webp' | 'png' | 'svg' | 'jpeg';
      consumers: string[];              // component paths that render this slot
      fallback: string;                 // path to fallback asset if generation fails
      cacheKey: string;                 // sha256(prompt+aspect+style+seed)
      brandTokens: Record<string, string>; // resolved tokens: BRAND_HEX, MISSION, etc.
    }
    ```
    
    ## The 30 Methods
    
    Prompts use `{{token}}` substitution from `_brand.json` + page data.
    
    ### 1 — Hero per route
    
    - **Use** — 16:9 cinematic illustration anchoring every top-level route hero
    - **Prompt** — *"editorial illustration of {{ROUTE_SUBJECT}} in {{CITY}}, cinematic lighting, {{BRAND_PALETTE}} color palette, painterly brush, soft golden hour, depth of field, no text"*
    - **Style** — REALISTIC · **Aspect** — 16:9 · **Path** — `/images/ideogram/hero-{route}.webp`
    - **Consumer** — `<Hero variant="cinematic">` · **Fallback** — existing source-site hero
    
    ### 2 — OG card per route
    
    - **Use** — 1200×630 designed social card with brand stripe + title overlay
    - **Prompt** — *"social media share card, deep {{BRAND_HEX}} gradient background, abstract organic shapes, subtle texture, leave clear negative space top-left for headline overlay, no text in image"*
    - **Aspect** — 16:9 (resized to 1200×630 post-gen) · **Consumer** — per-route metadata `og.image` · **Fallback** — `og-default.png`
    
    ### 3 — Twitter / X card variant
    
    - **Use** — 1200×600 (sharper crop); same base prompt as #2 with different seed
    - **Path** — `/images/ideogram/twitter-{route}.png` · **Consumer** — per-route `twitter.image`
    
    ### 4 — Section dividers
    
    - **Use** — 3:1 horizontal panoramic separators between major page sections
    - **Prompt** — *"thin horizontal banner, abstract topography map with {{BRAND_HEX}} contour lines on cream background, organic flow, no text, decorative"*
    - **Aspect** — 3:1 · **Format** — svg-or-png · **Consumer** — `<SectionDivider>` between pricing/features/cta blocks
    
    ### 5 — Parallax layer set
    
    - **Use** — Three 1:1 transparent-PNG layers (foreground, midground, background) for parallax hero scroll
    - **Prompt set** — *"transparent PNG, {{LAYER}} foliage silhouette in {{BRAND_HEX}}, no background, painterly"*
    - **Consumer** — `<ParallaxScene layers="3">` with `transform: translateY()` driven by scroll
    
    ### 6 — Article / card thumbnails
    
    - **Use** — 4:3 illustrative thumbnails for blog/news/case-study listings
    - **Prompt** — *"flat-vector editorial illustration of {{POST_SUBJECT}}, {{BRAND_PALETTE}}, isometric perspective, 4:3"*
    - **Consumer** — `<BlogCard>`, `<NewsCard>`
    
    ### 7 — Stat block icons
    
    - **Use** — 1:1 80×80 brand-tinted icons accompanying stat counters
    - **Prompt** — *"minimal line icon of {{STAT_SUBJECT}}, single-color {{BRAND_HEX}}, 1px stroke, geometric, centered on transparent background"*
    - **Format** — SVG · **Consumer** — `<StatBlock icon={...}>`
    
    ### 8 — Testimonial frames
    
    - **Use** — Branded portrait frames behind testimonial avatars
    - **Prompt** — *"decorative circular frame, hand-drawn laurel wreath in {{BRAND_HEX}}, transparent PNG, 256×256"*
    - **Consumer** — `<Testimonial>` overlaying `<img>`
    
    ### 9 — Impact-tier badges
    
    - **Use** — Donation/pricing tier illustrated badges (Tier 1 / 2 / 3)
    - **Prompt** — *"badge illustration of {{TIER_NAME}} with {{TIER_AMOUNT}} feeling, hand-drawn medallion style, {{BRAND_HEX}} ink, cream paper"*
    - **Consumer** — `<DonationTier>`, `<PricingTier>`
    
    ### 10 — Volunteer-role / job-role illustrations
    
    - **Use** — 3:2 portraits per role (cook, server, packer, ambassador, dev, designer)
    - **Prompt** — *"editorial illustration of {{ROLE_NAME}} at work in {{SETTING}}, warm lighting, {{BRAND_PALETTE}}, painterly"*
    - **Consumer** — `<RoleCard>` on `/volunteer` or `/careers`
    
    ### 11 — Timeline / era illustrations
    
    - **Use** — 16:9 scene-setter per timeline era (e.g. 1820s, 1900s, 1970s, present)
    - **Prompt** — *"sepia-toned editorial illustration of {{ERA_LABEL}} in {{CITY}}, {{ERA_DETAIL}}, painterly historical realism"*
    - **Consumer** — `<HistoryTimeline>` era headers
    
    ### 12 — Branded 404 mascot
    
    - **Use** — 1:1 friendly anthropomorphic mascot greeting lost visitors
    - **Prompt** — *"friendly mascot illustration for 404 page of {{BRAND_NAME}}, holding 'lost' sign, in {{BRAND_PALETTE}}, hand-drawn, no rendered text on sign"*
    - **Consumer** — `<NotFound>` route
    
    ### 13 — Donation-success confetti sprite sheet
    
    - **Use** — Animated success state confetti shapes (3:1 sprite strip)
    - **Prompt** — *"sprite sheet of 12 confetti shapes in {{BRAND_PALETTE}}, transparent PNG, evenly spaced, geometric"*
    - **Consumer** — confetti canvas on `/donate/thank-you`
    
    ### 14 — Newsletter signup art
    
    - **Use** — 4:3 inviting illustration adjacent to email signup form
    - **Prompt** — *"warm editorial illustration of opening a hand-written letter in {{BRAND_PALETTE}}, painterly, no text on paper"*
    - **Consumer** — `<NewsletterSignup>`
    
    ### 15 — Email signature header
    
    - **Use** — 4:1 thin banner for transactional email header (Resend / Inngest templates)
    - **Prompt** — *"thin email banner with {{BRAND_NAME}} wordmark area on left, abstract {{BRAND_HEX}} shapes on right, leave 30% left blank for logo overlay"*
    - **Output** — `/images/ideogram/email-header.png` · **Consumer** — Resend email templates
    
    ### 16 — PDF cover (annual report / press kit)
    
    - **Use** — A4 portrait cover for downloadable PDFs
    - **Prompt** — *"editorial annual-report cover for {{BRAND_NAME}} {{YEAR}}, central illustration of {{MISSION_VISUAL}}, painterly, {{BRAND_PALETTE}}, leave top 30% clear for typography overlay"*
    - **Aspect** — 2:3 · **Consumer** — Puppeteer/Playwright PDF generator
    
    ### 17 — Magazine-spread asset (multi-image collage)
    
    - **Use** — 16:10 multi-pane editorial spread for storytelling pages
    - **Prompt** — *"editorial magazine spread layout of {{STORY_THEME}}, 3-panel collage, {{BRAND_PALETTE}}, painterly, varied compositions"*
    - **Consumer** — `<StorySpread>` on case-study or about pages
    
    ### 18 — Sticker pack
    
    - **Use** — 4×4 grid of die-cut style stickers for printed swag + Slack/Discord emoji sets
    - **Prompt** — *"sticker sheet of 16 die-cut stickers for {{BRAND_NAME}} community, {{BRAND_PALETTE}}, varied subjects: heart, hands, plate, sun, lightbulb, etc, white outline border"*
    - **Output** — `/images/ideogram/stickers.png` + post-process to individual PNGs in `/public/images/ideogram/stickers/`
    - **Consumer** — download page + Slack workspace emoji
    
    ### 19 — Loading-state illustrations
    
    - **Use** — 1:1 friendly illustrations replacing "Loading..." spinners on slow routes
    - **Prompt** — *"friendly illustration of {{SUBJECT}} being prepared, {{BRAND_PALETTE}}, gentle motion sense, painterly"*
    - **Consumer** — `<Suspense fallback={<LoadingArt slot="..." />}>`
    
    ### 20 — Easter-egg art (Konami / footer / 404-deeper)
    
    - **Use** — Hidden delight unlocked by Konami code or repeated logo click
    - **Prompt** — *"surprising playful illustration of {{INSIDE_JOKE}}, hand-drawn, {{BRAND_PALETTE}}, only visible to the curious"*
    - **Consumer** — footer hidden div triggered by keyboard sequence
    
    ### 21 — Light / dark theme variants
    
    - **Use** — Every hero/illustration generated in BOTH light AND dark variants
    - **Prompt suffix split** — *"...on cream paper background"* vs *"...on midnight navy background"*
    - **Render** — manifest stores both paths; CSS `prefers-color-scheme` swaps via `<picture><source media="(prefers-color-scheme: dark)" srcset=...>`
    
    ### 22 — Reduced-motion poster frames
    
    - **Use** — Static poster frames replacing motion video for `prefers-reduced-motion: reduce` users
    - **Prompt** — *"single dramatic still frame of {{VIDEO_SCENE_SUBJECT}}, cinematic lighting, {{BRAND_PALETTE}}, painterly"*
    - **Consumer** — `<video poster={...} preload="metadata">` + CSS reduced-motion gate
    
    ### 23 — Logo lockups (horizontal + stacked + monogram)
    
    - **Use** — Three wordmark variants in different layouts
    - **Prompt** — *"clean wordmark logo lockup for {{BRAND_NAME}}, {{LAYOUT_VARIANT}}, {{BRAND_HEX}} ink, vector style, transparent background, NO illustrative elements, typography only"*
    - **Style** — DESIGN · **Slots** — `logo-horizontal.svg` | `logo-stacked.svg` | `logo-monogram.svg`
    - **Consumer** — `<Header>`, `<Footer>`, favicon source · **Validate** — via real-favicongenerator API
    
    ### 24 — Maskable PWA icon + full favicon kit source
    
    - **Use** — Single 1024×1024 master icon with 10% safe-zone padding to feed `real-favicongenerator` for all 9 favicon outputs + maskable variant
    - **Prompt** — *"app icon for {{BRAND_NAME}}, central monogram or symbol in {{BRAND_HEX}}, geometric, 10% safe-zone padding around edge, square 1:1, no text"*
    - **Consumer** — `site.webmanifest` `icons[]` + `apple-touch-icon` + `favicon.ico`
    
    ### 25 — Apple touch icon (180×180 polished)
    
    - **Use** — Higher-effort iOS home-screen icon (separate from #24 master, Apple rounded-corner expectation)
    - **Prompt** — *"iOS home screen app icon for {{BRAND_NAME}}, 180×180, central monogram on solid {{BRAND_HEX}}, no inset shadow (iOS applies it), no text"*
    - **Consumer** — `<link rel="apple-touch-icon">`
    
    ### 26 — Custom cursor glyph (desktop ≥1024px)
    
    - **Use** — 32×32 PNG cursor for desktop interactive zones (branded *resting* cursor only — NO follow cursor)
    - **Prompt** — *"minimalist cursor glyph, {{BRAND_HEX}} arrow with small heart accent, 32×32, transparent PNG, sharp pixel edges"*
    - **Consumer** — `body { cursor: url('/images/ideogram/cursor.png'), auto; }` inside `@media (min-width: 1024px) and (hover: hover)`
    
    ### 27 — Footer / page watermark
    
    - **Use** — 1:1 subtle watermark glyph rendered in `--watermark-color` (very low opacity)
    - **Prompt** — *"single decorative mark for {{BRAND_NAME}}, {{BRAND_HEX}} ink on transparent, geometric monogram, no text"*
    - **Consumer** — footer background-image with `opacity: 0.05`
    
    ### 28 — Press kit hero (high-res, 4K)
    
    - **Use** — 4096×2304 source asset for press release inclusion + magazine reprints
    - **Prompt** — same as #1 with `--res=2048` and `renderingSpeed: 'QUALITY'`
    - **Consumer** — `/press-kit` download page link
    
    ### 29 — Social-share variant per channel
    
    - **Use** — Distinct compositions tuned to each platform (LinkedIn = professional, Instagram = vibrant square, TikTok = vertical 9:16)
    - **Slots per route** — `share-linkedin-{route}.png` (1200×627) | `share-instagram-{route}.png` (1080×1080) | `share-tiktok-{route}.png` (1080×1920)
    - **Consumer** — Postiz / Buffer / native share API
    
    ### 30 — Ticker / scroll-progress glyph
    
    - **Use** — Small repeating SVG element used as marquee scroll ticker + scroll-progress indicator bar
    - **Prompt** — *"small decorative SVG glyph 32×32 of {{BRAND_SYMBOL}}, single color {{BRAND_HEX}}, geometric, tiles seamlessly"*
    - **Style** — DESIGN · **Format** — SVG · **Consumer** — `<ScrollProgress>` + `<Ticker>` (marquee.css `@keyframes ticker`)
    
    ## Catalog file (project-side)
    
    ```ts
    // src/data/ideogram/methods.ts
    import type { IdeogramSlot } from './_types';
    export const IDEOGRAM_METHODS: IdeogramSlot[] = [
      { id: 'hero-home', method: 1, prompt: '...', aspect: '16:9', styleType: 'REALISTIC', renderingSpeed: 'QUALITY', outputPath: '/images/ideogram/hero-home.webp', outputFormat: 'webp', consumers: ['src/pages/home.tsx'], fallback: '/images/hero-fallback.jpg', cacheKey: '', brandTokens: {} },
      // ...30 entries total, one per method × N routes/variants
    ];
    ```
    
    ## Pipeline script (one-time + idempotent)
    
    ```js
    // scripts/generate-ideogram-assets.mjs — pseudo-skeleton
    import { IDEOGRAM_METHODS } from '../src/data/ideogram/methods.js';
    import { resolveTokens, callIdeogramV3, writeManifest, hashSlot } from './_ideogram-lib.js';
    const manifestPath = 'public/_ideogram/manifest.json';
    const existing = await readManifest(manifestPath);
    for (const slot of IDEOGRAM_METHODS) {
      const resolved = resolveTokens(slot, brand);
      const key = hashSlot(resolved);
      if (existing[slot.id]?.cacheKey === key) continue; // skip cached
      const asset = await callIdeogramV3(resolved); // POST /api/v1/ideogram-v3/generate
      await saveAsset(asset, slot.outputPath);
      existing[slot.id] = { ...slot, cacheKey: key, generatedAt: new Date().toISOString() };
    }
    await writeManifest(manifestPath, existing);
    ```
    
    ## API contract (Ideogram v3)
    
    ```ts
    POST https://api.ideogram.ai/v1/ideogram-v3/generate
    Headers: { 'Api-Key': process.env.IDEOGRAM_API_KEY }
    Body: { prompt, negative_prompt?, aspect_ratio, rendering_speed: 'QUALITY', style_type, magic_prompt: 'ON', num_images: 1, seed? }
    Returns: { data: [{ url, prompt, resolution, is_image_safe, seed, style_type }] }
    ```
    
    ## Build gates
    
    - Manifest exists + every `IDEOGRAM_METHODS[i].outputPath` resolves to a real file in `public/` build output.
    - No slot has empty `prompt` after token resolution.
    - **Methods #1, #2, #3, #12, #23, #24, #25** = MANDATORY (build fail if missing).
    - Others = strongly recommended.
    - Delight-moment registry (`_iteration_log.json[current].delight_moments[]`) gets entries for #12, #13, #18, #20, #26 when shipped.
    
    ## Cost guardrail
    
    - **QUALITY** — ≈ $0.08/image · **DEFAULT** — ≈ $0.04 · **TURBO** — ≈ $0.02
    - Catalog of 30 methods × 5-15 routes = 150-450 images.
    - Use TURBO for variants (#3 twitter, #21 light/dark mirror, #29 social channel mirrors); QUALITY only for #1 hero, #16 PDF cover, #28 press kit.
    - Budget: ~$10-20 per full site generation. Cache aggressively — same prompt+seed returns cached asset.
    
    ## Reference incident (2026-05-11 — njsk.org)
    
    Brian's verbatim ask: *"Leverage creativity to fully include Ideogram assets using 30 different methods across the entire site."* Catalog codified here so future site builds fan all 30 methods automatically. Pipeline plug-in to skill 15 site-generation as Step 6b (post-research, pre-typecheck).
    
  • build-breaking-rules.md 48.1 KB
    ---
    name: "12 build-breaking media+orchestration rules"
    description: "Universal media gates: Media Slot Manifest + GPT Image 1.5 primary slot-fill + fail-CLOSED auto-regenerate, NotebookLM artifacts (podcast + infographic + explainer video), page-rendered image topic-relevance ≥8/10, blog featured image mandatory + GPT Image 1.5 fallback, migrated source-site asset R2 self-hosting, page media density (video + multi-source generation), full-width Google Maps Embed widget for physical addresses. 2026-05-11 EXTEND: 14 Ideogram leverage slots (logo triad + favicon set + per-route OG cards + hero typographic poster + chapter plates + editorial blog headers + branded 404/500 + PWA splashes + tier badges + chapter glyphs + pattern tile + stat numerals + share quote cards + iteration stamp), 16-source parallel multimedia fan-out (Pexels+Pixabay+Google CSE Image+Wikimedia+Internet Archive+LoC+NASA+Smithsonian+The Met+Europeana+Flickr Commons+YouTube+Vimeo+Coverr+Mixkit+Pexels Video), Google Custom Search Image API license-filtered, NotebookLM-generated + Podcast Index discovered podcast feeds (Google Podcasts API retired 2024), ATF hero video via Sora + Veo dual cascade with stock fallback, progressive media refresh per iteration, credits/colophon route. Migrated verbatim from rules/always.md 2026-05-03; extended per Brian directive 2026-05-11."
    metadata:
      version: "1.0.0"
      updated: "2026-05-03"
      effort: "high"
      context: "fork"
    license: "Rutgers"
    compatibility:
      claude-code: ">=2.0.0"
      agentskills: ">=1.0.0"
    ---
    
    # 12 — Build-Breaking Media + Orchestration Rules
    
    > **Model migration note (pass-75, 2026-06-09)**: References to `DALL-E 3` / `DALL-E` migrated to **GPT Image 1.5** (current OpenAI image-gen flagship); `GPT-4o` vision migrated to **GPT Image 2 vision** (current OpenAI multimodal flagship). Per `platform.openai.com/docs/deprecations`: DALL-E 2/3 removed from API 2026-05-12; GPT-4o retired 2026-02-13. 14-Ideogram leverage slot manifest, 16-source parallel multimedia fan-out, and topic-relevance ≥8/10 gates all unchanged — only API endpoint names updated. Cost ranges in this doc were computed against legacy DALL-E + GPT-4o pricing; re-verify against current GPT Image 1.5 / GPT Image 2 rates.
    
    Migrated from `~/.claude/rules/always.md` 2026-05-03.
    
    ## Every image slot (***MEDIA SLOT MANIFEST + FAIL-CLOSED AUTO-REGENERATE — UNIVERSAL — BUILD-BREAKING — supersedes "fetch-some-images-and-pick-later" patterns***)
    
    - Phase 0 step 1 enumerates EVERY image slot on EVERY route into `_media_slots.json` BEFORE any agent fans out.
    
    ### Slot record shape
    
    ```
    {slot_id, route, section, role, aspect, min_dims, topic_keywords, topic_intent,
     brand_palette, preferred_motion, source_chain, dalle_prompt, negative_prompt,
     relevance_floor, filled_by, filled_url, filled_score, regen_attempts}
    ```
    
    ### Per-slot GPT Image 1.5 prompt — 6 mandatory fields
    
    - Drafted in a single batched gpt-4o call (~$0.05/site). MUST encode:
      1. Page topic + intent verbatim
      2. Brand palette tokens inline hex
      3. Composition + aspect ratio
      4. Subject specificity (NEVER `"people"` — always `"octogenarian volunteer plating soup, soft window light, documentary style"`)
      5. Photographic technical specs (camera/lens/lighting/DoF — e.g. `"shot on Hasselblad, 85mm prime, golden hour, shallow DoF"`)
      6. Negative prompt block (`"no text, no watermarks, no logos, no extra fingers, no AI artifacts, no stock-photo cliches"`)
    
    ### Source-chain order
    
    1. Real-entity sources (Places/uploads/scrape)
    2. GPT Image 1.5
    3. Pexels
    4. Coverr
    5. Flux
    6. Brand-gradient
    
    - GPT Image 1.5 is PRIMARY slot-fill engine after real-entity exhaustion. Stock APIs run as parallel speed-pass fallback (instant return if GPT Image 1.5 hangs >15s) but GPT Image 1.5 output preferred at curation.
    
    ### Acceptance
    
    - Every slot MUST end with `filled_url != null AND filled_score >= relevance_floor` (default 8/10 GPT Image 2 vision).
    
    ### Failure handling
    
    - Failure (Pexels empty, NSFW flag, broken scrape, vision below floor) → REFINED-prompt regen via GPT Image 1.5 (`(original_prompt, vision_critique, relevance_floor)`), max 5 attempts × $0.08/img = $0.40/slot worst case.
    - After exhaustion: log to `_unfillable_slots.json`, ship brand-gradient floor, mark `published_with_warnings`, surface slot for manual replacement.
    - NEVER silent skip. NEVER substitute brand-gradient unless 5 regen attempts exhausted.
    
    ### Validators
    
    - `validate-media-slot-manifest.mjs` — every route enumerated, every slot record complete
    - `validate-no-empty-slots.mjs` — no `_unfillable_slots.json` entries on clean build, no slot below relevance_floor, no fallback-gradient unless exhaustion logged
    - `validate-dalle-slot-fill.mjs` — every GPT Image 1.5 prompt has all 6 mandatory fields
    - Daily GPT Image 1.5 spend tracked in `_dalle_daily.json` against `OPENAI_DAILY_BUDGET` (default $50) — exhaustion triggers Flux fallback for remainder of day.
    
    ## Every site (***NOTEBOOKLM ARTIFACTS — PODCAST + INFOGRAPHIC + EXPLAINER VIDEO — UNIVERSAL — BUILD-BREAKING — `notebooklm-orchestrator` agent runs Phase 0***)
    
    Every site ships THREE auto-generated NotebookLM-style artifacts before deploy:
    
    1. **Two-host audio podcast** — rendered on `/about` via `<audio>` Plyr embed + collapsible inline transcript + dual JSON-LD (`PodcastSeries` + `PodcastEpisode` with `partOfSeries` + `associatedMedia` + `transcript`) + RSS feed `/podcast.xml` (RSS 2.0 + iTunes namespace + podcast namespace 1.0, `<enclosure type="audio/mpeg">` + `<itunes:duration>` + `<podcast:transcript>` + `<podcast:chapters>`) + 3000×3000 JPEG cover art (Apple-required)
    2. **Infographic gallery** — rendered on `/about` via `[data-infographic-gallery]` containing ≥3 panels (mandatory mix: 1 data chart via Vega-Lite SVG from `_research.json.stats[]` + 1 process/flow panel via Recraft v3 SVG + 1 hero illustration via GPT Image 2 PNG with brand-converged per-slot prompt encoding all 6 mandatory fields per skill 12), each panel `data-zoomable` + `data-gallery="infographic"` + `data-caption-title` + `data-caption-description` per always.md "Every multi-image section" rule
    3. **Explainer video** — rendered BTF (second `<section>` inside `<main>`, immediately after hero) on `/` homepage via `[data-section="explainer-btf"]` containing `<stream src="<UID>" controls preload="metadata" poster="<R2>" primary-color="<brand-accent>" defaultTextTrack="en">` (Cloudflare Stream embed) + `<figcaption>` + collapsible inline transcript + JSON-LD `VideoObject` with `name` + `description` + `thumbnailUrl` + `contentUrl` + `embedUrl` + `uploadDate` + `duration` + `transcript` + `hasPart` chapters array (3-7 `Clip` entries with `startOffset` + `endOffset` + `url#t=N`)
    
    - Phase 0 step 2 enumerates `_notebooklm.json` manifest (mirrors Media Slot Manifest pattern) BEFORE Phase 1 page builds reference artifact URLs.
    
    ### Provider chains
    
    - **Podcast** — ElevenLabs Studio Create Podcast (`POST /v1/studio/podcasts` with `mode: conversation` + two voice IDs) → AutoContent API → `teng-lin/notebooklm-py` headless wrapper (only when client demands NotebookLM-format proof) → skip-with-warning
    - **Video** — HeyGen API ($1/min standard, $4/min Avatar IV 1080p, 60-90s talking-head) → Synthesia → Tavus → Veo 3.1 Fast 8s hero loop ($1.20) → skip-with-warning
    - **Infographic** — Vega-Lite (free deterministic) + Recraft v3 ($0.04/SVG) + GPT Image 2 ($0.06/img); fallback to Napkin AI ($39/mo) when Recraft + GPT-Image saturated
    
    ### Budgets
    
    - Per-site cost ceiling — $3.50
    - Daily ceiling — `NOTEBOOKLM_DAILY_BUDGET` (default $300 = ~100 sites) — exhaustion degrades to cheapest providers, NEVER blocks deploy
    
    ### Acceptance
    
    - Every artifact MUST end with `filled_url != null AND filled_score >= 8/10` (GPT Image 2 vision for cover art, gpt-4o-mini topical-relevance for transcript) OR exhausted-with-warning state — max 3 attempts per artifact.
    
    ### Source brief construction
    
    - gpt-4o-mini condenses `_research.json` + top-N `_corpus.json` + `_pdf_facts.json` into 8-15K-char `_podcast_source.md`; same brief feeds 75-second `_video_script.md` synthesis (gpt-4o, structured Hook 10s → Problem 15s → Solution 30s → Proof 10s → CTA 10s).
    
    ### Submission automation (post-deploy)
    
    - Apple Podcasts Connect (JWT API, `APPLE_PODCAST_KEY_ID` + `APPLE_PODCAST_PRIVATE_KEY`)
    - Spotify for Podcasters (manual UI)
    - Podcast Index (free instant `https://podcastindex.org/add`)
    - Amazon Music Podcasters
    - YouTube Music podcast directory (Google Podcasts deprecated 2024)
    
    ### Validators
    
    - `validate-podcast-on-about.mjs` — `<audio>` + dual JSON-LD + transcript ≥500 chars on `/about`
    - `validate-infographic-on-about.mjs` — `[data-infographic-gallery]` ≥3 panels with caption attrs on `/about`
    - `validate-explainer-video-btf.mjs` — `[data-section="explainer-btf"]` is 2nd `<section>` of `<main>` on `/` + `<stream>` + VideoObject JSON-LD with hasPart chapters
    - `validate-podcast-rss.mjs` — `/podcast.xml` 200 + valid RSS 2.0 + ≥1 `<item>` with audio enclosure
    
    Pipeline spec: `~/.agentskills/12-media-orchestration/notebooklm-pipeline.md`. Agent: `~/.claude/agents/notebooklm-orchestrator.md`.
    
    ## Every page-rendered image (***TOPIC-RELEVANCE GATE — UNIVERSAL — BUILD-BREAKING — vision-LLM scored***)
    
    - Every `<img>` on a route MUST score ≥8/10 on per-page semantic relevance via GPT Image 2 vision scoring: (a) page topic + intent, (b) image subject + composition, (c) brand-tone fit.
    - Lightbulb on `/volunteer` = fail. Mixed-gender adults on `women-and-children-services` = fail. Generic corporate handshake on soup-kitchen page = fail.
    
    ### Hero image preference order (STRICT)
    
    1. Original-source-hero IF quality ≥7/10
    2. Pexels-video-loop matching topic
    3. Pexels-image scoring ≥8
    4. GPT Image 1.5 per-slot prompt naming page topic + brand palette + subject specificity
    5. Brand-gradient fallback
    
    - Validator (`validate-image-relevance.mjs`): post-build GPT Image 2 vision scores `(page_topic, image_description) → relevance 0-10` for every image on every route; FAIL any score <8.
    
    ## Every blog/article post (***FEATURED IMAGE MANDATORY + GPT Image 1.5 FALLBACK — UNIVERSAL — BUILD-BREAKING — extends "Every page-rendered image"***)
    
    - Every blog/news/article/journal/case-study post MUST have a valid featured image (hero + OG card + listing thumbnail).
    
    ### Discovery chain
    
    1. Source post `<img>` with class containing `featured`/`hero`/`thumb`
    2. Source post first inline `<img>` ≥1024×768
    3. Source post `og:image` meta
    4. Source post `twitter:image` meta
    5. Pexels search by post title keywords (≥3 stop-word-filtered nouns)
    6. Pixabay search same keywords
    7. GPT Image 1.5 generation with per-slot prompt naming: (a) post topic + 3 most-relevant nouns from title; (b) brand palette inline hex; (c) 16:9 aspect for hero / 1.91:1 for OG / 4:3 for listing thumbnail; (d) photographic specs (`"editorial photography, soft natural light, shallow DoF, documentary style"`); (e) negative prompt block (no text, no watermarks, no logos, no AI artifacts)
    
    - When source-post-image is broken (404 / 5xx / mixed-content / blocked CDN), SKIP that source and continue chain — never ship a broken `<img>`.
    - Vision-LLM scores final image ≥8/10 topic relevance before accepting; below 8 → regen with refined prompt up to 3 attempts.
    - Validator (`validate-blog-featured-images.mjs`): every `_corpus.json.posts[]` entry MUST have `featured_image_url` set to a 200-OK file in build output; every `<article>` tile MUST have `<img>` ≥1024×768; OG card 1200×630 ≤100KB.
    
    ## Every migrated source-site asset (***R2 SELF-HOSTING — NEVER HOTLINK SQUARESPACE/WIX/WP CDNS — UNIVERSAL — BUILD-BREAKING***)
    
    - Every image/video/PDF/font/CSS/JS reference inherited from the source site MUST be downloaded during scrape and re-uploaded to R2 under `sites/<slug>/assets/<deterministic-hash>.<ext>` BEFORE rebuild reaches `published`.
    - NEVER ship: `<img src="https://images.squarespace-cdn.com/...">`, `<img src="https://static.wixstatic.com/...">`, `<img src="https://wp.com/...">`, `<source src="https://www.youtube.com/embed/<id>">` (YouTube embeds OK as iframes; raw assets NOT).
    - YouTube/Vimeo iframes preserved as-is (intentional embeds, not asset hotlinks).
    
    ### Pipeline
    
    1. Crawler enumerates all asset URLs into `_assets.json.external_refs[]`
    2. Parallel `fetch` with REAL_UA + `Accept-Encoding: identity` for binary integrity
    3. Compute SHA-256 → store under `sites/<slug>/assets/<hash>.<ext>`
    4. Rewrite all dist HTML/CSS to point to `/assets/<hash>.<ext>`
    5. Augment with augmented assets per skill 12 media-acquisition
    
    - Validator (`validate-no-cdn-hotlinks.mjs`): grep dist/ HTML for hostnames matching `squarespace-cdn|squarespace.com|wixstatic|wix.com|wp.com|wpcomstaging|files.wordpress|cdn.shopify|images.weserv|res.cloudinary` — any match outside whitelist (analytics, fonts, Google CSE) = fail.
    
    ## Every page (media density — ***FAVOR VIDEO + MULTI-SOURCE GENERATION***)
    
    Every page receives at minimum:
    
    1. ALL original media from corresponding source URL (images, videos, PDFs preserved)
    2. 1-2 supplemental GPT Image 1.5 / GPT-Image purpose-crafted originals (per-slot prompts per skill 12)
    3. ≥1 Pexels Video API result for hero/module background (`<video autoplay muted loop playsinline poster>`)
    4. Google Image Search top-3 HIGHLY-relevant images filtered by topic match score (vision-LLM ≥8)
    
    - Hero background preference order: original-source-hero-image → Pexels-video-loop → Pexels-image → GPT Image 1.5-generated → solid-brand-gradient. Never ship a hero with no media.
    
    ## Every image (***BUSINESS-TYPE SEMANTIC MISMATCH — FAIL CLOSED — extends topic-relevance gate — BUILD-BREAKING***)
    
    - GPT Image 2 vision topic-relevance ≥8/10 is necessary but NOT sufficient. A second binary check MUST run: "Is this image physically compatible with what a `<business_type>` business would show?"
    - E.g. clothing/shirts on a box factory = fail; ambulance on a bakery = fail; tropical beach on an accounting firm = fail.
    - Prompt: (a) business category from `_research.json.category`; (b) business type description from `_research.json.business_type_description`; (c) vision description of image. Answer YES/NO — any NO → regen with prompt refinement.
    - Build gate (`validate-business-type-image-match.mjs`): every `[data-slot]` image runs a GPT Image 2 vision-mini YES/NO category-fit call — any NO = fail with slot ID, image URL, and rejection reason.
    
    ## Every site rebuild with known source domain (***PRIMARY DOMAIN MEDIA EXTRACTION MANDATORY — BEFORE ANY AI GENERATION***)
    
    - Before invoking GPT Image 1.5 or any stock API, crawler MUST fully extract ALL media from the source business domain.
    
    ### Extraction pipeline
    
    1. Firecrawl/Playwright deep crawl primary domain with REAL_UA
    2. Extract every `<img src>`, `<picture>`, `<video poster>`, `<source src>`, CSS `background-image` URL, `og:image`, JSON `image` fields
    3. Filter: keep ≥200×200px, ≥10KB, non-icon/sprite/tracking-pixel
    4. Download + R2 self-host (never hotlink)
    5. Run every extracted image through GPT Image 2 vision topic-relevance + business-type-match gate
    6. Target: `source_image_count × 1.5` minimum total images in build
    
    - NEVER start AI image generation until step 5 exhausted. `_media_slots.json` MUST show `filled_by: "source_extract"` for every slot filled by real extraction before `filled_by: "dalle"` or `filled_by: "pexels"` slots.
    - Validator (`validate-source-media-extraction.mjs`): when `_research.json.source_domain` set, assert `_assets.json.extracted_images.length >= 1`.
    
    ## Every team headshot (***1:1 SQUARE CROP + CONSISTENT STYLE — UNIVERSAL — BUILD-BREAKING***)
    
    - (a) Square aspect ratio (1:1), face centered, cropped at shoulder level — never portrait (4:5, 3:4) or landscape unless the full team page uses a SINGLE consistent non-square format.
    - (b) Consistent style across ALL team members (same background tone, crop framing, shadow/border treatment) — never a mix of office photos + LinkedIn headshots + casual outdoor shots in the same grid.
    - (c) Resolution ≥400×400px source, served at 200×200 minimum rendered size.
    - (d) When source site has real headshots: download, AI-crop to 1:1 (Sharp `gravity: 'face'` or `sharp().resize(600,600,{fit:'cover', position:'face'})`), upload to R2.
    - (e) When source has NO headshot: GPT Image 1.5 generates a professional headshot — prompt: `"Professional headshot of [gender]-presenting person in [industry-appropriate attire], neutral gray background, soft studio lighting, 1:1 square crop, photorealistic, Hasselblad quality, no text, no watermarks"` — NEVER ship a team card without a photo.
    - (f) Face detection required — if Sharp face-detect returns 0 faces, reject and regenerate.
    - Validator (`validate-team-headshots.mjs`): for every `[data-card-type=person]`, assert `<img>` natural aspect ratio between 0.9 and 1.1 AND rendered bounding-rect is square within 5%.
    
    ## Every listing-grid image (***CONSISTENT ASPECT RATIO WITHIN GRID — UNIVERSAL — BUILD-BREAKING***)
    
    - All `<img>` elements within a single `[data-card-grid]` MUST share the SAME aspect ratio — 16:9 OR 4:3 OR 1:1 (portraits → 1:1; products → 4:3; hero/feature → 16:9).
    - All images cropped to the grid's target aspect using Sharp `fit:'cover'` BEFORE upload to R2 — never CSS-cropped via `object-fit: cover` alone without actual crop.
    - Each `<figure>` uses CSS `aspect-ratio: 16/9` (or equivalent) as a size placeholder.
    - Validator (`validate-grid-image-aspect.mjs`): for every `[data-card-grid]`, compute `getBoundingClientRect()` of each `<img>` inside — if `max(width/height) - min(width/height) > 0.05` across images = fail.
    
    ## Every page render (***ALT-TEXT DEDUP BAN — UNIVERSAL — BUILD-BREAKING***)
    
    - No two `<img>` elements on the same rendered route may share identical alt text (case-insensitive, whitespace-normalized).
    - Template ships `src/lib/altText.ts` with `dedupeAltText<T>(assets)` that appends caption/context suffix on duplicate, and `findDuplicateAlts<T>()` for validation. `deriveAltFromSrc(src)` provides last-resort titleization fallback.
    - Every loop rendering `<img>` from a data array MUST pipe the array through `dedupeAltText()` before mapping to JSX — never `.map(a => <img alt={a.alt} />)` raw.
    - Validator (`validate-alt-dedup.mjs`): per-route DOM walk extracts every `<img alt>`, normalizes, fails on any duplicate AND on any empty alt that lacks `role="presentation"` or `aria-hidden="true"`.
    
    ## Every route (***PER-ROUTE OG IMAGE — UNIVERSAL — BUILD-BREAKING***)
    
    - Every route MUST have a UNIQUE `og:image` and matching `twitter:image` — no shared site-wide `og-image.png` across routes. Each card is 1200×630 ≤100KB branded composition naming the page topic in headline + branded background + logomark + accent gradient.
    
    ### Discovery chain for per-route OG image
    
    1. When route has a hero image meeting topic-relevance ≥8/10, composite hero into 1200×630 with branded gradient overlay + page title text + logomark
    2. Else render branded card via Satori/Resvg SSR or Vercel OG Image API or Bannerbear API with template tokens `{title, eyebrow, accent, logo}` per `_brand.json`
    3. Fallback: site-default OG card logged to `_unfillable_og.json` for manual replacement
    
    - File path: `sites/<slug>/og/<route-hash>.png` (deterministic — `sha256(route)` first 8 chars).
    - `og:image` MUST point to absolute URL; `og:image:width = 1200`; `og:image:height = 630`; `og:image:type = image/png`; `og:image:alt = "<page-specific-description>"`.
    - Validator (`validate-route-og-image.mjs`): every route has og:image set; every og:image URL is unique across routes; every og:image URL HEAD-200 resolves; image dimensions exactly 1200×630; file size ≤100KB. Same checks apply to `twitter:image`.
    
    ## Every inline SVG in dist/ (***SVGO COMPRESSED — UNIVERSAL — BUILD-BREAKING***)
    
    - All `<svg>` elements embedded inline in HTML or referenced as SVG files MUST pass SVGO optimization as a build step. Expected size reduction: 30-60%.
    - Build pipeline MUST run `npx svgo --multipass` on all `.svg` source files AND on all `<svg>` blocks extracted from template HTML before final bundle.
    
    ### SVGO config
    
    ```js
    { plugins: ["preset-default", { name: "removeViewBox", active: false }] }
    ```
    
    - Validator (`validate-svgo.mjs`): compute ratio `svgo_size / original_size` for every `.svg` in dist/ — if ratio >0.85 (less than 15% reduction), flag for review; if ratio === 1.0 (no optimization ran) = fail.
    
    ## Every page rendering a street address (***PROGRESSIVE-ENHANCEMENT MAPS CONTEXT — UNIVERSAL — BUILD-BREAKING — extends "Every site with a physical address"***)
    
    - Every page that visually renders a street address (any `<address>` element OR `<AddressBlock>` OR string matching `\d+ [A-Z][a-z]+ (Street|St|Avenue|Ave|Road|Rd|Blvd|Lane|Ln|Drive|Dr)` rendered as page content — NOT just footer ambient NAP) MUST present that address with adjacent Google Maps visual context within the same DOM section.
    - Progressive enhancement — keep `<address>` element + dir-link, ADD the visual.
    - Required adjacency: map widget within same `<section>` or `<aside>` as the address OR within 1 viewport scroll on mobile (≤700px after the address).
    
    ### Three approved widget tiers
    
    1. **MapTile** — 320×320 lazy-mounted thumbnail with one pin, `aspect-ratio: 1/1`; used inline beside contact cards / in `/we-need` tiles / service-page callouts (≤30KB rendered)
    2. **MapBand** — full-bleed 16:9 or 21:9 banner with directions CTA overlay; used on `/contact` + service-detail pages + section dividers
    3. **MapDossier** — split-screen 50/50 address+hours+phone left, interactive map right; used on `/contact` hero and "where to find us" features
    
    - `<MapWidget variant="tile|band|dossier" address geo title aspectRatio>` shipped once in `src/components/map-widget.tsx`, never duplicated.
    - Lazy-mount via IntersectionObserver — iframe injected only when section enters viewport. Placeholder: Sharp-rendered Maps Static API PNG OR brand-gradient SVG with pin glyph. Reduced-motion: no autoplay pan, static initial frame.
    - NO MORE THAN 2 distinct map widgets per route; when 2 addresses on same page, single widget with 2 pins via `&q=` polyline encoding OR `place_id`.
    - Same-page `LocalBusiness` / `Place` / `Organization` JSON-LD MUST include `geo: { latitude, longitude }` matching embed coords within 50m.
    - Validator (`validate-address-map-adjacency.mjs`): for every page rendering an address, assert: (a) `<MapWidget>` OR `<iframe src*="google.com/maps/embed">` OR `<img src*="maps.googleapis.com/maps/api/staticmap">` OR `<noscript>` map fallback within same `<section>`/`<aside>`; (b) map widget count per route ≤2; (c) `LocalBusiness.geo` JSON-LD present. Fail codes: `address.map_adjacency_missing` | `address.map_widget_excess` | `address.geo_jsonld_missing`.
    - **Footer NAP exemption** — the always-present footer-block address requires only the single small footer embed — does NOT trigger per-section adjacency rule.
    
    ## Every site with a physical address (***FULL-WIDTH GOOGLE MAPS WIDGET — UNIVERSAL — BUILD-BREAKING for local-business + non-profit + restaurant + medical + retail + any site with NAP***)
    
    - Every site whose `_research.json` resolves a street address renders ONE full-width interactive map widget on `/contact` (mandatory) AND mirrors a smaller embed in the footer (recommended).
    - MUST use Google Maps Embed API via `<iframe>` (no JS-API key required for basic embeds, free unlimited loads, no CSP `script-src` change needed) — NOT the Maps JavaScript API.
    
    URL pattern:
    
    ```
    https://www.google.com/maps/embed/v1/place?key=<MAPS_EMBED_KEY>&q=<urlencoded-address>&zoom=15&maptype=roadmap
    ```
    
    - `MAPS_EMBED_KEY` set via `wrangler secret put MAPS_EMBED_KEY --env production`.
    - `<MapWidget>` must include: `loading="lazy"`, `referrerpolicy="no-referrer-when-downgrade"`, `allowfullscreen`, `aria-label="Interactive map showing <address>"`, wrapped in `<figure>` with `<figcaption>` linking the address.
    - `<noscript>` fallback renders Maps Static API image: `https://maps.googleapis.com/maps/api/staticmap?center=<lat>,<lng>&zoom=15&size=1200x675&markers=color:red%7C<lat>,<lng>&key=<MAPS_STATIC_KEY>`.
    - For DASHBOARD CSP: append `frame-src https://www.google.com https://maps.google.com`.
    - `LocalBusiness` JSON-LD on same page MUST include `geo: { @type: "GeoCoordinates", latitude, longitude }` matching embed coords exactly (rounded to 5 decimals = ~1.1m precision).
    - Mapbox GL JS alternative when `_brand.json.theme==="dark" AND brand_consistency_priority>0.8` (50k loads/month free, requires `MAPBOX_TOKEN`).
    - When `_brand.json.theme === "dark"` AND Mapbox NOT chosen, wrap `<iframe>` in `<div class="map-wrapper" data-theme="dark">` and apply: `filter: invert(90%) hue-rotate(180deg) saturate(0.5) contrast(1.1);`
    
    ### Custom logo marker pin
    
    - When `_research.json.address_lat` + `_research.json.address_lng` known AND `_brand.json.logo.original_icon_url` resolves HEAD-200: download logo icon; Sharp composite — 80×80 white circle bg, logo centered 50×50, SVG downward-pointing triangle 20×20 as pin stem (brand accent color); save as `sites/<slug>/assets/map-marker.png`; upload to R2; append `&markers=icon:<absolute-r2-url-to-marker>%7C<lat>,<lng>` to Embed URL OR inject via Mapbox `addImage`+`addLayer`.
    - Fallback: `&markers=color:red%7C<lat>,<lng>` when logo unavailable.
    - Validator (`validate-google-maps-widget.mjs`): for every site with `_research.json.address` set, assert: (a) `<iframe src*="google.com/maps/embed">` OR `<div data-mapbox>` present on `/contact`; (b) iframe `width` resolves to ≥90vw at desktop; (c) `LocalBusiness.geo` lat/lng matches embed `q` resolved coords within 50m; (d) when `_brand.json.theme==="dark"`, iframe has `filter` style applied. Fail codes: `map.embed_missing` | `map.not_full_width` | `map.geo_mismatch` | `map.dark_theme_filter_missing`.
    
    ## Every page (***MULTIMEDIA DENSITY #1 — ≥3 DISTINCT MEDIA TYPES PER ROUTE — UNIVERSAL — BUILD-BREAKING***)
    
    - Every page-rendered route MUST present ≥3 distinct media types — text-only pages are BUILD FAIL.
    - Allowed types: photograph (GPT Image 1.5 OR real-entity); illustration/icon system; video (hero OR embedded clip); audio (podcast OR voiceover OR ambient); data visualization (chart OR infographic OR animated counter); 3D/WebGL (Three.js OR Spline OR particle field); interactive widget (calculator OR quiz OR map OR timeline scrubber); generative SVG (animated brand pattern).
    - Distribution: hero — 1 media; feature section — 1-2 media; final CTA section — 1 media.
    - Validator (`validate-multimedia-density.mjs`): grep dist HTML per route, assert ≥3 distinct types in: `<img>`, `<video>`, `<audio>`, `<svg>` with animation, `<canvas>`, `<iframe data-media-embed>`.
    
    ## Every page (***MULTIMEDIA DENSITY #2 — VIDEO-FIRST IN HERO + 1 PER 1000 WORDS — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site MUST ship ≥1 hero video AND ≥1 additional embedded video per ~1000 words of body content.
    - Source order: source-site original videos (R2 self-hosted) → Sora generation (premium tier) → Coverr royalty-free → Pexels free 4K → AI-generated explainer via HeyGen (founder profile only).
    - All videos MUST be MP4 H.264 OR WebM VP9 ≤4MB, lazy-loaded via `<video preload="metadata">` + `loading="lazy"`, `autoplay+muted+playsinline+loop` ONLY for hero/ambient; embedded body videos require `<video controls>`. HLS permitted for videos >4MB.
    - Validator (`validate-video-density.mjs`): assert hero video present AND ratio of `<video>` elements to word count ≥1:1500.
    
    ## Every page (***MULTIMEDIA DENSITY #3 — DATA VIZ FOR EVERY QUANTITATIVE CLAIM — UNIVERSAL — BUILD-BREAKING***)
    
    - Every page with ≥3 quantitative claims (%, N, $, dates, comparisons) MUST surface ≥1 data visualization rendering those claims — bar chart OR line graph OR animated counter OR sparkline OR proportional treemap OR map heatmap.
    - Chart.js (≤30KB CDN) OR vanilla SVG (zero JS) OR observablehq embed OR D3.js (premium tier); visualization MUST tie to APA-cited data per `~/.claude/rules/citations.md` — viz title cites source inline.
    - Validator (`validate-quantitative-data-viz.mjs`): grep dist HTML for ≥3 quantitative patterns (`\d+%`, `\$\d+[KMB]`, `\d+x`, `\d+ (years|users|customers|countries)`) on a page, assert ≥1 `<canvas data-chart>` OR `<svg data-viz>` OR `<dl data-stat-grid>` AND viz includes APA citation in caption/footnote.
    
    ## Every page (***MULTIMEDIA DENSITY #4 — AUDIO PRESENCE — UNIVERSAL — BUILD-BREAKING — homepage minimum***)
    
    - Every site's homepage MUST embed ≥1 audio asset: NotebookLM podcast episode OR ElevenLabs founder voiceover OR audio brief OR ambient brand sound OR audio testimonial.
    - Player: `<audio controls preload="metadata">` with transcript expander for accessibility (WCAG 2.2 1.2.1). Audio plays only on user-gesture (no autoplay).
    - Validator (`validate-audio-presence.mjs`): assert homepage `<audio>` present + has `controls` + `<details data-transcript>` OR linked `.txt` transcript file.
    
    ## Every site (***PRE-RENDER MEDIA #1 — MEDIA FETCH PARALLEL WITH RESEARCH — UNIVERSAL — BUILD-BREAKING***)
    
    - Build orchestrator MUST kick off media-fetch tasks (logo extraction, source-site asset crawl, Pexels/Pixabay search, GPT Image 1.5 prompt drafting) in parallel with research phase — NOT sequentially after research completes.
    - Container spawns 3 worker tasks at boot: `research_task`, `brand_extraction_task`, `media_prefetch_task` — all 3 must complete before `content_synthesis_task` fires.
    - Validator (`validate-parallel-media-fetch.mjs`): parse build trace timestamps, assert `media_prefetch_task.started_at ≤ research_task.started_at + 5s` AND `media_prefetch_task.ended_at ≤ research_task.ended_at + 30s`.
    
    ## Every site (***PRE-RENDER MEDIA #2 — GPT Image 1.5 BATCH SUBMISSION — UNIVERSAL — BUILD-BREAKING***)
    
    - GPT Image 1.5 slot fill MUST batch-submit all prompts in a single concurrent burst (`Promise.all([...slots].map(generateDALLE))`) with concurrency limit of 10 (OpenAI rate limit) — NEVER sequential one-at-a-time.
    - Total time = max(slot generation time) ≈ 12-18s vs sum (5min+) sequential. Per-slot timeout 20s with retry-once on transient failure.
    - Validator (`validate-dalle-batch.mjs`): parse build trace, assert all GPT Image 1.5 API calls have overlapping `started_at` windows + max concurrent ≥5 + total GPT Image 1.5 phase duration ≤30s for ≤20 slots.
    
    ## Every site (***PRE-RENDER MEDIA #3 — MEDIA SLOT MANIFEST CACHED BY URL HASH — UNIVERSAL — BUILD-BREAKING — iter ≥2 incremental***)
    
    - On rebuild at `iteration_count >= 2`, build orchestrator MUST hash source-site media URL list (`sha256(sorted(source_images[]))`) and compare against `_media_slots.json[previous].source_hash`. If match: skip source-image re-download + reuse prior GPT Image 1.5 generations whose source-data lineage hash matches.
    - Only regenerate slots whose source data CHANGED OR whose vision-relevance score was below floor in prior iteration OR whose goody-queue entry mutates them.
    - Validator (`validate-media-cache-reuse.mjs`): when `iteration_count >= 2`, assert `_media_slots.json[current].slots_reused_count > 50%` of total slots AND build's GPT Image 1.5 API call count is ≤ `slots_regenerated_count`.
    
    ## Every build (***IDEOGRAM LEVERAGE — CADENCE + REGISTRY — UNIVERSAL — BUILD-BREAKING***)
    
    - Every build MUST generate ≥4 Ideogram v3 assets at slice 0; every progressive rebuild (iteration ≥2) MUST add ≥2 NEW Ideogram assets OR remix existing ones.
    - Cadence: iteration 1 — 4-6 foundation assets; iteration 2+ — 2-3 incremental adds.
    - Pipeline: brand extraction → `_brand.json` → Ideogram prompt template `(brand_name, palette, font_family, asset_role, dimensions, style="vector|editorial|stamp|poster")` → `ideogram.generate({ model: "v3-balanced", style_type, magic_prompt_option: "AUTO", rendering_speed: "QUALITY", color_palette, num_images: 4 })` → critique pass (GPT Image 2 vision ≥8/10) → R2 upload → log to `_ideogram_assets.json`.
    - If Ideogram API quota exhausted, scan source-site Wayback/live for existing branded type/posters/banners — preserve + upscale before generating.
    - Validator (`validate-ideogram-cadence.mjs`): assert `_ideogram_assets.json[iteration].length >= floor_for_iteration` AND every entry has `{role, prompt, r2_url, dimensions, critique_score, source: "ideogram_v3" | "source_site_preserved"}`.
    
    ## Every build (***IDEOGRAM SLOT #1 — LOGO + WORDMARK + MONOGRAM SUITE — UNIVERSAL — BUILD-BREAKING***)
    
    - Slice 0 brand kit MUST include Ideogram-generated logo + wordmark + monogram triad: **Logo** (full lockup, icon + wordmark, header/footer); **Wordmark** (type-only, email signatures, watermarks); **Monogram** (initials/icon-only, favicon, app icon, social avatar, large faded BG watermark).
    - Prompt template: `"<brand_name> logo design, <style_directive_from_brand_research>, <palette> color palette, vector style, transparent background, professional"`.
    - Validator (`validate-logo-triad.mjs`): assert `_brand.json.logo.{full,wordmark,monogram}` all resolve 200 AND match brand palette ΔE≤5.
    
    ## Every build (***IDEOGRAM SLOT #2 — FAVICON-SET GENERATOR FROM MONOGRAM — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site MUST derive the 9-asset favicon set (`favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, `apple-touch-icon-180x180.png`, `android-chrome-192x192.png`, `android-chrome-512x512.png`, `mstile-150x150.png`, `safari-pinned-tab.svg`, `maskable-icon-1024x1024.png`) from Ideogram-rendered monogram — NEVER from automated resize of full logo (loses fidelity at 16x16).
    - Pipeline: Ideogram monogram @ 1024×1024 → ImageMagick downscale + sharp recompress → realfavicon API verification.
    - Validator (`validate-favicon-set.mjs`): assert all 9 files exist in `dist/` AND `favicon-16x16.png` retains recognizable mark (pHash distance ≤12 from 1024×1024 source).
    
    ## Every build (***IDEOGRAM SLOT #3 — OG/TWITTER CARD PER ROUTE — UNIVERSAL — BUILD-BREAKING***)
    
    - Every route MUST ship its own Ideogram-generated OG card (1200×630) AND Twitter card (1200×675) — NEVER reuse a single site-wide card. Each card features the route's H1 in brand typography over branded background pattern + brand logo lockup.
    - Prompt template per route: `"social share card design, headline '<route_h1>', <brand_name> logo bottom-right, <palette>, <font_family>, 1200x630, modern editorial"`.
    - Validator (`validate-og-card-per-route.mjs`): assert `dist/og/<route-slug>.png` exists for every `RouteMetadata.path` AND `og:image` meta points to it AND image dims === 1200×630.
    
    ## Every build (***IDEOGRAM SLOT #4 — HERO TYPOGRAPHIC POSTER (THESIS PLATE) — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site MUST ship 1 hero typographic poster — large brand wordmark or thesis statement as editorial-poster typography for above-the-fold accent (sits behind/beside video BG OR replaces video on low-bandwidth fallback).
    - Prompt template: `"editorial poster design, large bold typography '<one_line_thesis>', <brand_name>, <palette>, abstract <theme_from_research> background motif, minimalist, magazine-cover style"`.
    - Validator (`validate-hero-poster.mjs`): assert `_ideogram_assets.json[].role === "hero_poster"` exists with critique ≥8/10.
    
    ## Every build (***IDEOGRAM SLOT #5 — SECTION-DIVIDER CHAPTER PLATES — UNIVERSAL — BUILD-BREAKING***)
    
    - Long-form pages (about, history, services, blog post >2000 words) MUST insert Ideogram-generated chapter-divider plates between major sections — typographic art renderings of section H2s. Floor: 1 plate per 3 H2s on long pages.
    - Prompt template: `"editorial chapter divider, large typography '<h2_text>', <brand_name> color palette, <font_family>, minimalist horizontal banner 1600x400, magazine-style section break"`.
    - Validator (`validate-chapter-plates.mjs`): assert long-form route renders ≥`ceil(h2_count/3)` plates in DOM with `<figure data-role="chapter-plate">` AND each `<img>` resolves to a unique Ideogram asset.
    
    ## Every build (***IDEOGRAM SLOT #6 — EDITORIAL BLOG POST HEADERS — UNIVERSAL — BUILD-BREAKING***)
    
    - Every blog/journal post MUST have an Ideogram-generated editorial header image (1600×900) combining post title typography + relevant visual motif — replaces default GPT Image 1.5 stock-photo header on content-rich routes.
    - Prompt template: `"editorial article header, title '<post_title>', <subject_motif_from_post_body>, <palette>, <font_family>, magazine-cover composition, 16:9"`.
    - Validator (`validate-blog-headers.mjs`): assert every `_blog.json[].featured_image` derived from Ideogram OR explicitly tagged `source: photographic` with justification.
    
    ## Every build (***IDEOGRAM SLOT #7 — 404 + 500 BRANDED ERROR PAGES — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site MUST ship Ideogram-generated 404 + 500 hero art — typography-first error pages that feel brand-native.
    - Prompt templates: **404** — `"playful '404' typography in <brand> style, friendly error illustration, <palette>, <brand_name> logo small bottom"`; **500** — `"abstract '500' typography in <brand> style, calm error illustration, <palette>"`.
    - Validator (`validate-branded-error-pages.mjs`): assert `dist/404.html` + `dist/500.html` render Ideogram-sourced hero `<img>` AND status codes 404/500 served by worker route map.
    
    ## Every build (***IDEOGRAM SLOT #8 — PWA SPLASH SCREENS — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site MUST ship Ideogram-generated PWA splash screens at iOS + Android required sizes: **iOS** — 640x1136, 750x1334, 1242x2208, 1242x2688, 1536x2048, 1668x2388, 2048x2732; **Android** — 320x568, 360x640, 412x732, 480x800. Each splash = monogram centered on brand-gradient BG with brand wordmark below.
    - Prompt template: `"PWA splash screen, <brand_name> monogram centered, <palette> gradient background, wordmark below, <dimensions>, mobile splash design"`.
    - Validator (`validate-pwa-splashes.mjs`): assert all 11 splash sizes resolve in `dist/splash/` AND manifest references them via `apple-touch-startup-image` + Workbox precache.
    
    ## Every build (***IDEOGRAM SLOT #9 — PRICING TIER BADGES — SaaS/MEMBERSHIP MODE — BUILD-BREAKING***)
    
    - SaaS / membership / non-profit-tier sites MUST ship Ideogram-generated tier badges (Starter/Pro/Patron/Founder/Sustainer) — embossed typographic seals NOT generic icon-pack medals.
    - Prompt per tier: `"premium membership badge design, '<tier_name>' typography, embossed style, <palette>, <brand_name> mark, circular seal, gold/silver/bronze accent for tier hierarchy"`.
    - Validator (`validate-tier-badges.mjs`): assert pricing-page renders Ideogram badge per pricing tier in `_pricing.json.tiers[]`. Skipped on portfolio/local-business builds.
    
    ## Every build (***IDEOGRAM SLOT #10 — NUMBERED CHAPTER GLYPHS — UNIVERSAL — BUILD-BREAKING***)
    
    - Every feature/process/services section with ≥3 steps MUST render Ideogram-generated numbered chapter glyphs (`"01"`, `"02"`, `"03"`...) as section markers — bold numeral typography in brand style.
    - Prompt template: `"large bold numeral '<NN>' typography, editorial magazine style, <brand_name> color palette, <font_family>, single-character, transparent background, vector"`.
    - Validator (`validate-chapter-glyphs.mjs`): assert `_ideogram_assets.json[].role === "chapter_glyph"` count ≥ max(step_count, 3) across site.
    
    ## Every build (***IDEOGRAM SLOT #11 — BRAND PATTERN / WATERMARK TILE — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site MUST ship 1 Ideogram-generated repeating pattern tile (256×256 or 512×512) using brand monogram + secondary motif — applied as footer BG, large faded watermark, or section divider texture.
    - Prompt template: `"seamless repeating pattern tile, <brand_name> monogram motif, <palette>, subtle texture, geometric or organic per brand voice, 512x512 tileable"`.
    - Validator (`validate-pattern-tile.mjs`): assert `dist/assets/pattern.png` or `pattern.svg` exists AND used in CSS as `background-image: url(...)` with `background-repeat` in ≥1 route.
    
    ## Every build (***IDEOGRAM SLOT #12 — STAT-ROLLUP NUMERAL CARDS — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site with quantitative trust signals (years founded, members served, projects completed, $ raised) MUST render those numerals as Ideogram-generated typography cards — NOT plain CSS-rendered numbers.
    - Prompt per stat: `"giant numeral '<value>' typography card, label '<unit>' below, <brand_name> palette, editorial poster style, 800x800, magazine cover"`.
    - Validator (`validate-stat-numerals.mjs`): assert `_research.json.stats[]` items each have an Ideogram asset rendered in DOM with IntersectionObserver count-up animation (skill 11 rule).
    
    ## Every build (***IDEOGRAM SLOT #13 — SHAREABLE QUOTE / TESTIMONIAL CARDS — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site with testimonials/quotes/manifesto MUST render social-shareable quote cards via Ideogram — 1:1 (Instagram) + 9:16 (Stories/TikTok) + 16:9 (LinkedIn).
    - Prompt template: `"social media quote card, '<quote_text>' typography in <brand_font>, attribution '<author>, <role>', <palette>, <aspect>, modern editorial design"`.
    - Validator (`validate-share-quote-cards.mjs`): assert every testimonial in `_testimonials.json` has ≥2 aspect variants in `dist/share/` AND share buttons download or open-in-share-sheet the matching aspect.
    
    ## Every build (***IDEOGRAM SLOT #14 — PROGRESSIVE-BUILD ITERATION STAMP/SEAL — UNIVERSAL — BUILD-BREAKING***)
    
    - Every progressive rebuild (iteration ≥1) MUST generate an Ideogram "iteration N" stamp/seal — embossed circular mark like `"Build vN · Refined 2026-05-11 · By AI + Brian"` surfaced on `/admin/build-trace` AND as Easter-egg footer watermark on hover.
    - Prompt per iteration: `"vintage certification stamp, 'Build v<N>' typography, date '<iso_date>', <brand_name> monogram center, <palette>, circular seal, embossed gold/silver"`.
    - Validator (`validate-iteration-stamp.mjs`): assert `_ideogram_assets.json[].role === "iteration_stamp"` increments per `sites.iteration_count`.
    
    ## Every build (***MULTIMEDIA DENSITY — FREE API MULTI-SOURCE — UNIVERSAL — BUILD-BREAKING***)
    
    - Every build MUST populate every page-rendered experience with multimedia from ≥5 free/optimal APIs IN PARALLEL — concurrency floor: ≥7 API calls overlapping.
    - Required APIs: Pexels, Pixabay, Google Custom Search Image API, Wikimedia Commons API, Internet Archive, Library of Congress, NASA Images, Smithsonian Open Access, The Met Open Access, Europeana, Flickr Commons, YouTube Data API, Vimeo, Coverr, Mixkit, Pexels Video.
    
    ### Pipeline
    
    ```
    media_orchestrator.fanOut(topic, palette, license=CC-BY-or-permissive)
      → Promise.all([pexels, pixabay, googleCSE, wikimedia, archiveOrg, loc, nasa, smithsonian, met, europeana, flickrCommons, youtube, vimeo, coverr, mixkit, pexelsVideo])
      → dedupe by pHash
      → license-filter (CC-BY+/CC0/public-domain only)
      → relevance score via GPT Image 2 vision (≥7/10)
      → R2 self-host
      → emit `_media_corpus.json[{source, license, attribution, r2_url, relevance_score, license_url}]`
    ```
    
    - Validator (`validate-media-density.mjs`): assert every content section has ≥3 media items AND ≥5 distinct sources represented across build AND every asset has license metadata + attribution rendered in `/credits` page.
    
    ## Every build (***GOOGLE CUSTOM SEARCH IMAGE API — LICENSE-FILTERED — UNIVERSAL — BUILD-BREAKING***)
    
    - Every build MUST query Google Custom Search JSON API with `searchType=image` + `rights=cc_publicdomain,cc_attribute,cc_sharealike,cc_noncommercial` for every topic in `_research.json.topics[]` + every named entity in `_corpus.json`.
    
    API:
    
    ```
    https://www.googleapis.com/customsearch/v1?key=<GOOGLE_CSE_KEY>&cx=<GOOGLE_CSE_CX>&q=<topic>&searchType=image&rights=cc_attribute&imgSize=large&safe=active
    ```
    
    - Floor: ≥10 Google CSE Image queries per build; ≥3 candidates per query; ≥1 selected per page.
    - Pair with reverse-image lookup (Tineye-free / Google Lens via Vision API) to verify license claims before R2-self-hosting.
    - Validator (`validate-google-image-corpus.mjs`): assert `_media_corpus.json` contains ≥10 entries with `source: "google_cse"` AND every entry passes license verification.
    - Required keys: `GOOGLE_CSE_KEY` (`https://console.cloud.google.com/apis/credentials`); `GOOGLE_CSE_CX` (`https://programmablesearchengine.google.com/controlpanel/all`).
    
    ## Every build (***PODCAST GENERATION — NOTEBOOKLM + PODCAST INDEX DUAL-MODE — UNIVERSAL — BUILD-BREAKING***)
    
    - Every build MUST produce 1 NEW NotebookLM-generated "Deep Dive" podcast episode (2-host conversation, 8-15min) per iteration AND embed ≥3 DISCOVERED relevant existing podcast episodes via Podcast Index API. Google Podcasts API deprecated 2024.
    - (a) **NotebookLM API** for generation — `_research.json` + `_corpus.json` uploaded as sources → trigger Audio Overview → poll → download MP3 → R2 self-host → embed via custom audio player on `/about` + `/podcast` route.
    - (b) **Podcast Index API** (`https://podcastindex.org/`, free, no key required) for discovery — `GET /api/1.0/search/byterm?q=<topic_or_brand>` → filter by recency + relevance → embed top 3 via iframe player.
    - (c) **YouTube Data API v3** for podcast discovery — `GET /search?q=<topic>+podcast&type=video&videoDuration=long`.
    - Pipeline: parallel fan-out → assemble `_podcasts.json[{type:"generated|discovered", source, audio_url, transcript_url, duration_s, hosts, embed_html}]` → `/podcast` route renders chronological feed + RSS feed (`/podcast/feed.xml` per RFC 5005) → submit to Apple Podcasts + Spotify on first iteration.
    - Validator (`validate-podcast-presence.mjs`): assert `_podcasts.json.generated.length >= 1` per iteration AND `_podcasts.json.discovered.length >= 3` AND `/podcast/feed.xml` validates against PodcastIndex spec.
    - Required keys: `NOTEBOOKLM_API_KEY` (Google Cloud Vertex AI); `YOUTUBE_API_KEY` (`https://console.cloud.google.com/apis/credentials`).
    
    ## Every build (***ATF VIDEO BACKGROUND — SORA + VEO + STOCK FALLBACK — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site's homepage hero AND every long-form route hero MUST ship an above-the-fold video background.
    
    ### Generation cascade (parallel, take-first-success)
    
    - (a) **OpenAI Sora 2** primary — `POST /v1/videos` with `prompt: "<brand_thesis_scene_description>, cinematic, 10s, 16:9, 1080p, no text"` + `duration_seconds: 10` + `aspect_ratio: "16:9"` → poll task → download MP4
    - (b) **Google Veo 3** parallel — Vertex AI `predictLongRunning` with `prompt` + `aspectRatio:"16:9"` + `durationSeconds: 8` + `personGeneration: "allow_adult"` → poll → download
    - (c) **Stock video fallback** (when generation quota/budget exhausted) — Pexels Video API `GET /videos/search?query=<theme>&orientation=landscape&size=large` OR Coverr `/api/v1/videos/search` OR Mixkit free-tier — must be CC0/permissive
    
    - Encode to WebM (VP9) + MP4 (H.264) at 1920×1080 + mobile 854×480, total ≤4MB per video.
    
    ```html
    <video autoplay muted loop playsinline preload="metadata" poster="<ideogram_hero_poster_fallback>">
      <source type="video/webm">
      <source type="video/mp4">
    </video>
    ```
    
    - Respect `prefers-reduced-motion` — render Ideogram poster instead. Lazy-load secondary route heroes via IntersectionObserver.
    - Validator (`validate-atf-video.mjs`): assert homepage hero has `<video>` with valid src AND fallback poster AND total page weight ≤5MB AND `prefers-reduced-motion` gracefully degrades.
    - Required keys: `OPENAI_API_KEY` (Sora); `GCP_VEO_KEY` or `GOOGLE_APPLICATION_CREDENTIALS` (Veo via Vertex AI); `PEXELS_API_KEY` (stock fallback).
    
    ## Every progressive build (***PROGRESSIVE MEDIA REFRESH — UNIVERSAL — BUILD-BREAKING***)
    
    - Every iteration ≥2 MUST refresh ≥1 hero video + add ≥2 NEW Ideogram assets + expand `_media_corpus.json` by ≥10 items — never re-serve identical media across iterations.
    - Iteration cadence: iteration 1 — baseline Sora hero; iteration 2 — Veo variant + new chapter plates; iteration 3 — Sora seasonal variant + new pattern tile + new tier badges.
    - Validator (`validate-progressive-media.mjs`): for iteration ≥2, assert `_media_corpus.json[current].length >= _media_corpus.json[prior].length + 10` AND hero video pHash differs from prior iteration's pHash.
    
    ## Every build (***CREDITS + ATTRIBUTION PAGE — UNIVERSAL — BUILD-BREAKING***)
    
    - Every site MUST ship a `/credits` (or `/colophon`) route rendering full attribution for every multimedia asset — image source + license + author + URL. Sourced from `_media_corpus.json` automatically. Required by Wikimedia/CC-BY licenses + ethical defaults.
    - Validator (`validate-credits-page.mjs`): assert `/credits` renders ≥1 entry per `_media_corpus.json` item AND each entry has author link + license link + source URL AND robots = `index,follow`.
    
  • compression-pipeline.md 3.8 KB
    ---
    name: "compression-pipeline"
    description: "Image/video compression targets and automation scripts for WebP, AVIF, MP4"
    updated: "2026-04-23"
    ---
    
    # Compression Pipeline
    
    ## Python Compression
    
    ```python
    from PIL import Image
    import subprocess
    
    def optimize_image(input_path, output_path, max_width=1200, quality=80):
        """Compress and convert to WebP."""
        img = Image.open(input_path)
    
        # Resize if wider than max_width
        if img.width > max_width:
            ratio = max_width / img.width
            img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
    
        # Save as WebP (best compression for web)
        img.save(output_path.replace('.png', '.webp'), 'WEBP', quality=quality, method=6)
    
        # Also save JPEG fallback
        if img.mode == 'RGBA':
            img = img.convert('RGB')
        img.save(output_path.replace('.png', '.jpg'), 'JPEG', quality=quality, optimize=True)
    ```
    
    ## Size Verification
    
    ```python
    import os
    MAX_SIZES = {
        'hero': 200_000,      # 200KB
        'feature': 100_000,   # 100KB
        'icon': 20_000,       # 20KB
        'og': 150_000,        # 150KB
        'thumbnail': 30_000,  # 30KB
    }
    
    def check_size(path, category='hero'):
        size = os.path.getsize(path)
        limit = MAX_SIZES.get(category, 200_000)
        if size > limit:
            print(f"WARNING: {path} is {size/1000:.0f}KB (limit: {limit/1000:.0f}KB)")
            return False
        return True
    ```
    
    ## Compression Standards
    
    - **WebP (photo)** — quality 80%, max 200KB, via `cwebp` or `sharp`
    - **WebP (illustration)** — quality 90%, max 150KB, via `cwebp` or `sharp`
    - **PNG (logo/icon)** — lossless, max 50KB, via `pngquant`
    - **SVG** — optimized, max 10KB, via `svgo`
    - **MP4 (hero video)** — CRF 28, max 2MB, via `ffmpeg`
    - **MP4 (feature video)** — CRF 26, max 5MB, via `ffmpeg`
    - **ICO** — multi-resolution, max 15KB, via `ImageMagick`
    
    ## Image Dimensions
    
    - **Hero (desktop)** — 1920x1080, WebP
    - **Hero (mobile)** — 750x1334, WebP
    - **Feature icon** — 128x128, SVG or WebP
    - **Testimonial headshot** — 96x96, WebP
    - **OG image** — 1200x630, PNG
    - **Blog header** — 1200x675, WebP
    - **Logo (horizontal)** — 240xauto, SVG or PNG
    - **Favicon** — 16/32/48/180/192/512, ICO/PNG
    
    ## Delivery via Cloudflare
    
    ```html
    <!-- Responsive images -->
    <picture>
      <source media="(max-width: 768px)" srcset="/images/hero-mobile.webp">
      <source media="(min-width: 769px)" srcset="/images/hero-desktop.webp">
      <img src="/images/hero-desktop.webp" alt="..." loading="eager" decoding="async">
    </picture>
    
    <!-- Below-fold images -->
    <img src="/images/feature.webp" alt="..." loading="lazy" decoding="async">
    ```
    
    ## Cloudflare Image Transforms
    
    Store originals in R2, transform on-the-fly via URL params — no pre-generated variants:
    
    ```
    https://domain.com/cdn-cgi/image/width=800,quality=75,format=auto/path/to/image.jpg
    ```
    
    - `format=auto` serves AVIF/WebP based on browser support
    - Each variant cached at the edge automatically
    - Sub-50ms delivery globally
    
    ## Preventing CLS (Layout Shift)
    
    - Always include `width` and `height` attributes on `<img>` tags
    - Use CSS `aspect-ratio` for responsive containers:
    
    ```css
    .image-container {
      aspect-ratio: 16/9;
      overflow: hidden;
    }
    .image-container img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
    ```
    
    ## Broken Image Detection (Playwright)
    
    ```typescript
    test('no broken images', async ({ page }) => {
      await page.goto('/');
      const images = page.locator('img');
      const count = await images.count();
      for (let i = 0; i < count; i++) {
        const img = images.nth(i);
        const complete = await img.evaluate(el => (el as HTMLImageElement).complete);
        const naturalWidth = await img.evaluate(el => (el as HTMLImageElement).naturalWidth);
        const src = await img.getAttribute('src');
        expect(complete, `Image not loaded: ${src}`).toBe(true);
        expect(naturalWidth, `Broken image: ${src}`).toBeGreaterThan(0);
      }
    });
    ```
    
  • image-optimization.md 6.6 KB
    ---
    name: "Image Optimization Pipeline"
    description: "Sharp for server-side image processing: resize, WebP/AVIF conversion, responsive srcset generation (320w-1920w), blur placeholders. Pipeline integrates with Uppy uploads via post-upload Worker, stores optimized variants in R2. Cloudflare Image Resizing as on-demand alternative."
    updated: "2026-04-23"
    ---
    
    # Image Optimization Pipeline
    
    ## Sharp Processing Worker
    
    ```typescript
    // src/workers/image-processor.ts
    import sharp from 'sharp';
    
    interface ImageVariant {
      width: number;
      suffix: string;
    }
    
    const VARIANTS: readonly ImageVariant[] = [
      { width: 320, suffix: '320w' },
      { width: 640, suffix: '640w' },
      { width: 1280, suffix: '1280w' },
      { width: 1920, suffix: '1920w' },
    ] as const;
    
    const FORMATS = ['webp', 'avif'] as const;
    
    interface ProcessedImage {
      key: string;
      format: string;
      width: number;
      size: number;
    }
    
    async function processImage(
      env: Env,
      originalKey: string,
      buffer: ArrayBuffer,
    ): Promise<{ variants: ProcessedImage[]; blurDataUrl: string; dominantColor: string }> {
      const image = sharp(Buffer.from(buffer));
      const metadata = await image.metadata();
      const variants: ProcessedImage[] = [];
    
      // Generate all size × format combinations
      for (const variant of VARIANTS) {
        if (variant.width > (metadata.width ?? 9999)) continue; // Skip upscaling
    
        for (const format of FORMATS) {
          const quality = format === 'webp' ? 80 : 70; // Visually lossless
          const processed = await sharp(Buffer.from(buffer))
            .resize(variant.width, undefined, { fit: 'inside', withoutEnlargement: true })
            .toFormat(format, { quality })
            .toBuffer();
    
          const key = originalKey.replace(/\.[^.]+$/, `-${variant.suffix}.${format}`);
          await env.R2.put(key, processed, {
            httpMetadata: { contentType: `image/${format}` },
            customMetadata: { originalKey, width: String(variant.width), format },
          });
    
          variants.push({ key, format, width: variant.width, size: processed.byteLength });
        }
      }
    
      // Blur placeholder (tiny 20px base64)
      const blurBuffer = await sharp(Buffer.from(buffer))
        .resize(20, undefined, { fit: 'inside' })
        .blur(10)
        .webp({ quality: 20 })
        .toBuffer();
      const blurDataUrl = `data:image/webp;base64,${blurBuffer.toString('base64')}`;
    
      // Dominant color extraction
      const { dominant } = await sharp(Buffer.from(buffer)).stats();
      const dominantColor = `rgb(${dominant.r},${dominant.g},${dominant.b})`;
    
      return { variants, blurDataUrl, dominantColor };
    }
    ```
    
    ## Post-Upload Queue Handler
    
    ```typescript
    // src/queues/image-queue.ts
    import { Hono } from 'hono';
    
    interface ImageMessage {
      key: string;
      contentType: string;
    }
    
    export default {
      async queue(batch: MessageBatch<ImageMessage>, env: Env): Promise<void> {
        for (const message of batch.messages) {
          const { key, contentType } = message.body;
          if (!contentType.startsWith('image/')) {
            message.ack();
            continue;
          }
    
          const object = await env.R2.get(key);
          if (!object) { message.ack(); continue; }
    
          const buffer = await object.arrayBuffer();
          const result = await processImage(env, key, buffer);
    
          // Store metadata in D1
          await env.DB.prepare(
            `UPDATE files SET variants = ?, blur_data_url = ?, dominant_color = ?, processed = 1 WHERE key = ?`
          ).bind(JSON.stringify(result.variants), result.blurDataUrl, result.dominantColor, key).run();
    
          message.ack();
        }
      },
    };
    ```
    
    ## Responsive Image Component (Angular)
    
    ```typescript
    // responsive-image.component.ts
    import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
    
    @Component({
      selector: 'app-responsive-image',
      standalone: true,
      changeDetection: ChangeDetectionStrategy.OnPush,
      template: `
        <picture>
          <source
            type="image/avif"
            [srcset]="avifSrcset"
            [sizes]="sizes" />
          <source
            type="image/webp"
            [srcset]="webpSrcset"
            [sizes]="sizes" />
          <img
            [src]="fallbackSrc"
            [alt]="alt"
            [width]="width"
            [height]="height"
            [loading]="eager ? 'eager' : 'lazy'"
            decoding="async"
            [style.background-color]="dominantColor"
            [style.background-image]="blurDataUrl ? 'url(' + blurDataUrl + ')' : ''"
            style="background-size: cover" />
        </picture>
      `,
    })
    export class ResponsiveImageComponent {
      @Input({ required: true }) baseUrl!: string;
      @Input({ required: true }) alt!: string;
      @Input() width = 0;
      @Input() height = 0;
      @Input() eager = false;
      @Input() sizes = '(max-width: 640px) 100vw, (max-width: 1280px) 50vw, 33vw';
      @Input() blurDataUrl = '';
      @Input() dominantColor = '#060610';
    
      get webpSrcset(): string {
        return [320, 640, 1280, 1920].map((w) => `${this.variantUrl(w, 'webp')} ${w}w`).join(', ');
      }
      get avifSrcset(): string {
        return [320, 640, 1280, 1920].map((w) => `${this.variantUrl(w, 'avif')} ${w}w`).join(', ');
      }
      get fallbackSrc(): string { return this.variantUrl(1280, 'webp'); }
    
      private variantUrl(width: number, format: string): string {
        return this.baseUrl.replace(/\.[^.]+$/, `-${width}w.${format}`);
      }
    }
    ```
    
    ## Cloudflare Image Resizing (On-Demand Alternative)
    
    ```typescript
    // No pre-generation needed — CF resizes on first request, caches at edge
    // Requires CF Pro+ plan with Image Resizing enabled
    
    function cfImageUrl(originalUrl: string, width: number, format: 'webp' | 'avif' = 'webp'): string {
      return `/cdn-cgi/image/width=${width},format=${format},quality=80/${originalUrl}`;
    }
    
    // Usage in HTML: srcset with CF transform URLs
    // <img srcset="/cdn-cgi/image/width=320,format=webp/hero.jpg 320w, /cdn-cgi/image/width=640,format=webp/hero.jpg 640w" />
    ```
    
    ## Uppy Integration (trigger processing after upload)
    
    ```typescript
    // In upload success handler, queue image for processing
    uploads.post('/presign', zValidator('json', uploadSchema), async (c) => {
      // ... presign logic ...
      // After successful upload confirmation:
      await c.env.IMAGE_QUEUE.send({ key, contentType });
      return c.json({ key, publicUrl });
    });
    ```
    
    ## wrangler.toml Bindings
    
    ```toml
    [[queues.producers]]
    queue = "image-processing"
    binding = "IMAGE_QUEUE"
    
    [[queues.consumers]]
    queue = "image-processing"
    max_batch_size = 5
    max_retries = 3
    ```
    
    ## Quality Settings
    
    - **WebP** — quality 80 (SSIM ~0.98, visually lossless)
    - **AVIF** — quality 70 (same perceptual quality, 30-50% smaller than WebP)
    - Never upscale
    - Skip variants wider than original
    - **Max single image after optimization** — <200KB
    - **Total page images** — <500KB
    - **Hero** — eager + preload
    - **Everything else** — lazy, `decoding=async`
    
  • image-profiling.md 3.3 KB
    ---
    name: "image-profiling"
    description: "Tiered vision profiling: Workers AI Llama Vision (free, bulk) → GPT Image 2 vision (paid, hero/logo picks only). Scores, placement, alt text, colors."
    updated: "2026-04-25"
    ---
    
    # Image Profiling (Tiered Vision)
    
    Bridge between visual assets and text-only AI builders. Profile every candidate image BEFORE the build so the builder makes informed placement decisions without seeing images.
    
    ## Architecture (***COST-TIERED***)
    
    ### Tier 1 — Workers AI Llama Vision (FREE, 90% of images)
    
    - Model: `@cf/meta/llama-3.2-11b-vision-instruct`
    - Batch 5 images/call, 3 batches parallel = 15 images/round
    - Handles: description, keywords, quality_score, suggested_placement, alt_text, dominant_colors
    - Sufficient for gallery/about/services/background images
    
    ### Tier 2 — GPT Image 2 vision detail:low ($0.01/call, 10% of images)
    
    - Hero candidates only (top 5 by Tier 1 score) + logo variants + brand color extraction
    - Single batch call with all hero candidates
    - Worth the spend — hero is 80% of first impression
    
    ## Profile Schema
    
    ```json
    {
      "name": "hero-storefront.webp",
      "url": "https://r2.example.com/assets/hero-storefront.webp",
      "source": "discovered|generated|scraped|uploaded",
      "description": "Warm interior shot of a busy coffee shop with exposed brick walls",
      "keywords": ["coffee", "interior", "warm", "cozy", "brick"],
      "quality_score": 8,
      "relevance_score": 9,
      "suggested_placement": "hero|about|services|gallery|team|testimonials|background",
      "alt_text": "Interior of Main Street Coffee with customers at wooden tables",
      "dominant_colors": ["#8B4513", "#F5F5DC", "#2F4F4F"]
    }
    ```
    
    ## System Prompt Pattern
    
    ```
    You are an expert visual curator for professional websites. For each image:
    1. Describe what's in it (2 sentences max)
    2. Rate quality 1-10 (composition, lighting, resolution, professionalism)
    3. Rate relevance 1-10 (how well it fits a {business_type} website)
    4. Suggest ONE placement: hero|about|services|gallery|team|testimonials|background
    5. Write descriptive alt text (SEO-friendly, includes business context)
    6. Extract 3-5 dominant hex colors
    Return JSON array matching the profile schema.
    ```
    
    ## Top-Pick Selection Algorithm
    
    1. Sort by `quality_score * 0.4 + relevance_score * 0.6` descending
    2. **Hero** — highest combined score, prefer wide/landscape, quality ≥ 7
    3. **Logo** — source=uploaded|discovered with "logo" in name/description, else generated
    4. **About** — top 3 by relevance with "interior"|"team"|"story" keywords
    5. **Services** — top 3 matching service-related keywords
    6. **Gallery** — remaining images quality ≥ 6, deduplicated by dominant_colors similarity
    
    ## Integration with Build Pipelines
    
    Pre-container: collect 50-100 candidate images from all APIs → batch profile → select top picks → write `_image_profiles.json` as context file. Builder reads profiles, uses every top-pick in its suggested placement. Alt text pre-written. No guessing, no vision needed in build step.
    
    ## Cost Management
    
    - **Workers AI** — FREE (included in Workers Paid plan). 60 images = $0.00.
    - **GPT Image 2 vision** — 1 batch call for top 5 hero candidates ≈ $0.02.
    - **Total profiling cost** — ~$0.02/site (down from $0.60-1.20).
    - Skip duplicates (hash-based dedup before profiling)
    - Skip images <100px or >5MB
    - Timeout: 30s per batch call, 90s total phase
    
  • lightbox-classifier.md 10 KB
    ---
    name: "lightbox-classifier"
    description: "Classifier for which images become lightbox-eligible. Logos NEVER lightbox; below-1024×768 NEVER lightbox; quality-score-<7 NEVER lightbox. Logo grids become hover-grayscale-to-color, not zoom carousels. Every multi-image grid MUST carry data-gallery; every standalone zoomable image MUST carry data-lightbox."
    updated: "2026-05-04"
    ---
    
    # Lightbox Eligibility Classifier (***NON-NEGOTIABLE — RUNS PER IMAGE***)
    
    An image is lightbox-eligible if AND ONLY IF all three conditions met. Anything else is forbidden — clicking a logo to "zoom in" is a UX failure that cheapens the brand.
    
    ## Eligibility Rule (`inferLightboxEligibility(profile) → boolean`)
    
    ```ts
    function inferLightboxEligibility(p: ImageProfile): boolean {
      if (p.kind === 'logo') return false;                    // RULE 1: logos never lightbox
      if (p.kind === 'institution_logo') return false;        // sponsor/credentials/trusted-by walls
      if (p.kind === 'social_icon') return false;
      if (p.kind === 'favicon') return false;
      if (p.width < 1024 || p.height < 768) return false;      // RULE 2: low-res looks bad zoomed
      if ((p.gpt4o_quality_score ?? 0) < 7) return false;      // RULE 3: ugly zoomed = stays small
      return true;
    }
    ```
    
    ## Forbidden Combinations
    
    NEVER lightbox:
    
    - Logos
    - Institutional/sponsor/partner/trusted-by logo grids
    - Social media icons
    - Favicons
    - UI iconography
    - Decorative SVGs
    - Hero-as-decoration backgrounds
    - Tile backgrounds
    - Thumbnails of off-site videos
    - Screenshots <1024px wide
    
    Build gate: grep dist HTML/JSX/TSX for `data-gallery="logos"|"trusted"|"sponsors"|"partners"|"credentials"|"institutions"|"clients"` → any match fails build (skill 07).
    
    ## Lightbox-Eligible by Section
    
    - Hero photos (≥1280×720)
    - Gallery sections
    - Service detail photography
    - Team headshots (only if ≥1024×1024 — most aren't, skip otherwise)
    - Before/after sliders (separate UX, not lightbox)
    - Press photo libraries
    - Event photos
    - Product photos
    - Restaurant food photos
    
    Each must pass all 3 rules.
    
    ## Same-Section Grouping + Integrated Captions (***UNIVERSAL — BUILD-BREAKING***)
    
    When a section/page contains ≥2 lightbox-eligible images on the SAME topic (gallery, team, services, before/after, press, event, food, product, portfolio), ALL of them MUST share ONE `data-gallery="<section-slug>"` ID — never split into per-image groups, never mix unrelated topics in the same group.
    
    Section-slug = kebab-cased section heading or route segment (`hero-team` | `programs-2024-gala` | `services-pediatric` | `food-menu-mains`). Building separate galleries `data-gallery="img-1"`, `data-gallery="img-2"` (the lone-mountain-global-3 incident, 2026-05-01) defeats the purpose — user clicks one, can't navigate to siblings, has to close and re-open per image.
    
    Build gate (`validate-lightbox-grouping.mjs`): for every section element containing ≥2 `[data-zoomable]` descendants, assert all share ONE `data-gallery` value — multiple distinct values inside same `<section>` / `[data-section]` ancestor = fail. Cross-section grouping forbidden — never `data-gallery="all-photos"` spanning the whole page.
    
    Every lightbox-eligible image MUST carry `{ title, description, credit?, link? }` caption metadata sourced from:
    
    1. Source-site `<figcaption>`
    2. Source-site `alt` (when ≥6 words and not filename-like)
    3. AI-generated 8-15 word description via GPT Image 2 vision
    4. Manual brand voice pass
    
    Render captions in TWO places:
    
    - **(a)** Section UI as `<figcaption>` (gallery grid) OR overlay-on-hover (hero/cards) with `aria-describedby` linking image to caption text
    - **(b)** Lightbox modal as bottom strip (NOT corner badge) with title (16px semibold) + description (14px regular) + credit if present + link button when source-cite URL exists
    
    Render via `data-caption-title`, `data-caption-description`, `data-caption-credit`, `data-caption-link` attributes on `<img>` — lightbox JS reads them on open.
    
    Build gate (same validator): `[data-zoomable]` without `data-caption-title` + `data-caption-description` = fail; captions must round-trip identically across section UI + modal (compare DOM text content, fail on mismatch). Captions must clear small-text contrast bar (≥7:1 vs scrim, see `rules/always.md`).
    
    ## Logo Grid Treatment (***INSTEAD OF LIGHTBOX***)
    
    Logo grids (sponsors / trusted-by / partners / credentials / press / clients) render as hover-grayscale-to-color rows:
    
    ```css
    .logo-grid img {
      filter: grayscale(1) brightness(1.1);
      opacity: 0.65;
      transition: filter 0.3s var(--ease-out), opacity 0.3s var(--ease-out);
    }
    .logo-grid img:hover, .logo-grid img:focus { filter: none; opacity: 1; }
    ```
    
    Wrap each in `<a>` if logo points to source (journal homepage, sponsor site, client case study). `target="_blank" rel="noopener"`. Min target 24×24px (WCAG 2.5.8) — usually 80-120px logo height satisfies.
    
    ## ImageProfile Augmentation (skill 12 `image-profiling.md`)
    
    Per-image GPT Image 2 vision call returns:
    
    ```ts
    interface ImageProfile {
      width: number; height: number;
      kind: 'photo'|'illustration'|'logo'|'institution_logo'|'social_icon'|'favicon'|'icon'|'screenshot'|'diagram';
      gpt4o_quality_score: number;  // 1-10
      has_white_background: boolean;
      white_bg_corner_samples: [number, number, number, number];  // RGB avg per corner
      dominant_colors: string[];
      suggested_placement: string[];
      is_lightbox_eligible: boolean;  // computed via inferLightboxEligibility
    }
    ```
    
    Builder reads `profile.is_lightbox_eligible` and only wraps with `data-zoomable` / `data-gallery` when true.
    
    ## Edge Cases
    
    - **Mixed grids** (some photos + some logos) — split into two grids: photo gallery (lightbox), logo strip (hover treatment). Never mix.
    - **Composite images** (photo with logo overlay) — treat as photo, lightbox-eligible if rules pass; never crop logo onto a separate lightbox slide.
    - **Stock photos used as decoration** — `kind='photo'` but if `gpt4o_quality_score<7`, `is_lightbox_eligible=false` → render small without zoom.
    
    ## Runtime YARL Configuration (***BUILD-BREAKING — RUNS IN BROWSER***)
    
    Two distinct layers:
    
    - **(A)** Build-time profiling above sets `data-gallery`/`data-lightbox` attributes
    - **(B)** Runtime `isEligible()` gate in `lightbox.tsx` fires on every click
    
    Both must agree or images open inconsistently.
    
    ### Mandatory attribute contracts
    
    - Every multi-image section container: `data-gallery="<section-slug>"` on the wrapper div, `cursor-zoom-in` class on every `<img>` inside
    - Every standalone zoomable image: `data-lightbox="<name>"` directly on `<img>`, `cursor-zoom-in` class
    - Images in `header`, `footer`, `<a>`, `button`, `[data-no-zoom]`: NEVER get either attribute. Lightbox will skip them.
    
    ### Runtime `isEligible()` — canonical implementation (copy verbatim into `lightbox.tsx`)
    
    ```ts
    function isEligible(img: HTMLImageElement): boolean {
      if (img.closest('header, footer, a, [data-no-zoom], button')) return false;
      // Explicit opt-in bypasses size check — data-gallery/data-lightbox always wins
      if (img.dataset.lightbox || img.dataset.gallery || img.closest('[data-lightbox],[data-gallery]')) return true;
      // Rendered layout size — offsetWidth is reliable for lazy-not-loaded images; img.width can be 0
      const w = img.naturalWidth || img.offsetWidth || img.width;
      const h = img.naturalHeight || img.offsetHeight || img.height;
      return w >= 80 && h >= 80;  // 80px NOT 200px — column grids at 4-wide reach ~196px on max-w-4xl
    }
    ```
    
    Key threshold: **80px** (not 200px). Rationale: 4-column grid at `max-w-4xl` (~56rem ÷ 4 = ~196px per column) falls under 200px, breaking the entire partner/gallery grid. Use `offsetWidth` not `img.width` — `img.width` returns 0 for `loading="lazy"` images not yet in viewport.
    
    ### `findGallery()` isolation behavior
    
    When `img` is inside `[data-gallery="id"]`, `findGallery()` collects ONLY images inside that same named group — never bleeds into adjacent sections. Without `data-gallery`, it walks up the DOM until it finds a container with ≥2 eligible siblings. Always prefer `data-gallery` to guarantee isolation.
    
    ### `markZoomable()` cursor sync
    
    Runs every 1500ms (for lazy-loaded late arrivals). Applies `cursor: zoom-in` and `data-zoomable="true"` to all eligible images. Requires the same opt-in check: `data-lightbox`/`data-gallery` → zoomable regardless of size; else size must be ≥80×80px.
    
    ### Attribute checklist per page section
    
    - **Hero photo** — container: `data-lightbox="<page>-hero"` on `<img>`; classes: `cursor-zoom-in`
    - **2-column photo grid** — container: `data-gallery="<page>-photos"` on wrapper div; classes: `cursor-zoom-in`
    - **4-column partner grid** — container: `data-gallery="partner-photos"` on wrapper div; classes: `cursor-zoom-in`
    - **Blog post photo gallery** — container: `data-gallery="post-photos"` on wrapper div; classes: `cursor-zoom-in`
    - **Standalone service img** — no attribute needed (size ≥80px passes auto); classes: `cursor-zoom-in`
    - **Logo / icon / social** — NONE; `data-no-zoom` if risk of false-positive; no `cursor-zoom-in`
    
    ## Verification (skill 07)
    
    Visual gate: Playwright clicks one logo from a `.logo-grid` element on the deployed site → asserts NO `[role="dialog"][aria-modal="true"]` appears. Click one image from `[data-gallery]` → asserts dialog appears within 2000ms. Both must hold for build to pass.
    
    Playwright full-verify script (`lightbox-full-verify.mjs`) MUST be run as pre-deploy gate: iterates every `[data-gallery]`, `[data-lightbox]`, and standalone `img[data-zoomable]` on all routes, clicks each, asserts `.yarl__container` visible. Any failure = build fail.
    
    ## Anti-Pattern Examples (***CAUGHT IN POST-DEPLOY VISUAL QA***)
    
    **lone-mountain-2 (2026-05-01)** — Boston University, Harvard, Colgate logos rendered into a `data-gallery="institutions"` lightbox carousel; clicking each opened a 1200×800 modal with a 200×80 grayscale logo centered on dark backdrop. Looked broken. Fix: classifier promotes these to `kind='institution_logo'` → forbidden from lightbox → render as hover-grayscale-to-color row under credentials list, each linking to the institution's homepage.
    
  • media-prompts.md 3.6 KB
    ---
    name: "media-prompts"
    description: "Prompt templates for Ideogram logos, GPT Image hero shots, Sora video, and stock photo curation"
    updated: "2026-04-23"
    ---
    
    # Media Generation Prompt Templates
    
    ## Image Prompt Schema
    
    ```
    Subject: [main subject]
    Style: [art style — digital illustration, photograph, 3D render, etc.]
    Mood: [emotional tone — professional, energetic, calm, bold]
    Color palette: [specific colors from brand]
    Composition: [framing — centered, rule of thirds, wide angle]
    Background: [specific background description]
    Lighting: [lighting style — soft, dramatic, studio, natural]
    Details: [specific details that matter]
    Avoid: [what to exclude — text, watermarks, specific elements]
    Aspect ratio: [dimensions]
    ```
    
    ### Image Prompt Rules
    
    - Be specific about style, not generic (`"dark tech illustration with cyan accent glow"` not `"cool image"`)
    - Include brand colors explicitly
    - Specify what to AVOID (text in images, watermarks, specific unwanted elements)
    - For product screenshots, use browser rendering instead of generation
    - For people, specify diversity and professional context
    - Never add detail the user didn't imply — augment generic prompts, preserve specific ones
    
    ## Brian's Brand Prompt Style
    
    ```
    Dark, atmospheric [subject]. Cyan (#00E5FF) light streaks and purple (#7C3AED) nebula effects
    on deep black (#060610). Quantum-inspired dots and connections. Premium tech aesthetic.
    Ultra-wide composition. Ultra realistic. No text.
    ```
    
    ## Logo Prompt Templates (Ideogram v3)
    
    ### Horizontal Lockup
    
    ```
    a premium, clean, modern logo for "{BusinessName}", a {industry} business.
    Minimal design with the text "{BusinessName}" in a bold sans-serif font.
    {brand_color} accent color on dark transparent background. Professional,
    scalable, no gradients, vector-style. PNG with transparency.
    ```
    
    ### Icon/Monogram
    
    ```
    a minimal icon logo using the letter "{FirstLetter}" for "{BusinessName}",
    a {industry} business. Clean geometric shape, {brand_color} color,
    transparent background, works at very small sizes. Modern, professional.
    ```
    
    ### Wordmark
    
    ```
    a premium wordmark logo that spells "{BusinessName}" in elegant, custom
    typography. {brand_color} on transparent background. Think Apple, Stripe,
    or Linear level of typographic quality. No icons, text only.
    ```
    
    ## OG Image Template Prompt
    
    ```
    Create a social media preview image for [product name].
    Dimensions: 1200x630 pixels.
    Background: dark (#060610) with subtle gradient.
    Text: "[Product Name]" in large, bold white text (Space Grotesk).
    Subtitle: "[Tagline]" in smaller cyan (#00E5FF) text below.
    Include a simple iconic element representing [product purpose].
    No photographs, no busy backgrounds.
    Clean, modern, tech-forward design.
    ```
    
    ## Video Prompt Template (Sora)
    
    ```
    [Opening shot/setup, 0-2s]: [describe initial frame]
    [Main action, 2-6s]: [describe what happens]
    [Closing/hold, 6-8s]: [describe ending state]
    
    Style: [cinematic, documentary, animated, etc.]
    Camera: [static, slow pan, tracking, drone]
    Lighting: [natural, studio, dramatic, ambient]
    Color: [brand-consistent palette]
    Mood: [match product tone]
    ```
    
    ## Ideogram v3 API Call
    
    ```bash
    curl -X POST "https://api.ideogram.ai/v1/ideogram-v3/generate" \
      -H "Api-Key: $IDEOGRAM_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "prompt": "a minimalist tech logo with the text \"BrandName\" in cyan #00E5FF, dark background, sans-serif",
        "aspect_ratio": "ASPECT_16_9",
        "rendering_speed": "DEFAULT"
      }'
    ```
    
    - Put desired text in quotation marks within the prompt
    - Upload up to 3 style reference images for consistency
    - **TURBO mode** — ~4s generation at $0.03-0.05 per image
    
  • notebooklm-pipeline.md 22 KB
    ---
    name: "notebooklm-pipeline"
    description: "Per-site podcast (two-host audio overview) + infographic gallery + explainer video — built from research corpus, embedded on /about (podcast+infographic) + / homepage BTF (video). RSS feed + Apple Podcasts/Spotify submission + JSON-LD."
    updated: "2026-05-02"
    ---
    
    # NotebookLM Pipeline
    
    Every site ships 3 NotebookLM-style artifacts auto-generated from `_research.json` + `_pdf_facts.json` + `_corpus.json`:
    
    1. **Two-host audio podcast** — rendered on `/about` + listed at `/podcast.xml` RSS
    2. **Infographic gallery** (≥3 panels: data chart + branded panel + hero illustration) — rendered on `/about`
    3. **Explainer video** (60-90s talking-head OR 8s hero loop) — rendered BTF (second screen) on `/` homepage
    
    Pipeline runs in Phase 0 alongside Media Slot Manifest enumeration so artifacts exist BEFORE Phase 1 page builds reference them. Total cost ceiling ~$3/site (podcast $0.40 + infographic $0.60 + video $1.50 + hosting + TTS overhead $0.50). Daily spend tracked in `_notebooklm_daily.json` against `NOTEBOOKLM_DAILY_BUDGET` (default $300/day = ~100 sites).
    
    ## Artifact Manifest (`_notebooklm.json` — Phase 0 step 2)
    
    Mirrors `_media_slots.json` shape so the same fail-CLOSED auto-regenerate pattern applies.
    
    ### Schema
    
    ```json
    {
      "podcast": {
        "title": "<Brand>: <One-Sentence Pitch>",
        "subtitle": "Two-host audio overview generated from research corpus",
        "duration_target_sec": 1800,
        "duration_actual_sec": null,
        "host_a": { "name": "Sage", "voice_id": "21m00Tcm4TlvDq8ikWAM", "persona": "skeptical journalist" },
        "host_b": { "name": "River", "voice_id": "AZnzlk1XvdvUeBnXmlld", "persona": "curious enthusiast" },
        "transcript_url": "<R2>/podcast/transcript.json",
        "audio_url": "<R2>/podcast/episode-01.mp3",
        "cover_url": "<R2>/podcast/cover-3000.jpg",
        "rss_guid": null,
        "filled_by": null,
        "filled_score": null,
        "regen_attempts": 0,
        "provider_chain": ["elevenlabs-studio", "autocontent-api", "notebooklm-py-headless", "fallback-skip-with-warning"]
      },
      "infographic": {
        "panels": [
          { "panel_id": "data-chart", "type": "vega-lite", "topic_intent": "...", "data_source": "_research.json.stats", "svg_url": null, "filled_score": null },
          { "panel_id": "process-flow", "type": "recraft-svg", "topic_intent": "...", "prompt": "...", "svg_url": null, "filled_score": null },
          { "panel_id": "hero-illustration", "type": "gpt-image-2", "topic_intent": "...", "prompt": "...", "png_url": null, "filled_score": null }
        ],
        "filled_panels": 0,
        "regen_attempts": 0
      },
      "explainer_video": {
        "duration_sec": 75,
        "format": "talking-head",
        "script": "...",
        "captions_vtt_url": null,
        "video_url": null,
        "cf_stream_uid": null,
        "poster_url": null,
        "filled_score": null,
        "regen_attempts": 0,
        "provider_chain": ["heygen-avatar-iv", "synthesia", "tavus", "veo-3.1-fast-fallback-loop", "skip-with-warning"]
      },
      "video_description": {
        "title": "<60 chars>",
        "description": "<400-2000 chars, paragraph 1 = quotable 40-60 word answer block>",
        "chapters": [{ "label": "Intro", "start_sec": 0 }, { "label": "Problem", "start_sec": 12 }, ...],
        "tags": ["..."],
        "transcript_url": "<R2>/video/transcript.vtt"
      }
    }
    ```
    
    ## Audio Podcast — Two-Host Overview (***PRIMARY: ElevenLabs Studio***)
    
    ### Provider order
    
    1. **ElevenLabs Studio Create Podcast** (`POST /v1/studio/podcasts` with `mode:"conversation"`, two voice IDs, source text = condensed research brief 8-15K chars) — deterministic REST API, no browser automation, ships finished MP3 + transcript JSON. ~$0.30/episode at 30 min
    2. **AutoContent API** ($39/mo unlimited, NotebookLM-format mimicry) — when ElevenLabs quota exhausted
    3. **`teng-lin/notebooklm-py`** wrapper — ONLY in CF Container with feature flag `NOTEBOOKLM_BROWSER=1`; use when client demands "made in NotebookLM" as marketing claim
    4. **Skip-with-warning** — surface in dashboard for manual upload
    
    NEVER block deploy on podcast — it's enrichment, not infrastructure.
    
    ### Source brief construction (Phase 0 step 2a)
    
    gpt-4o-mini condenses `_research.json` + top-N pages of `_corpus.json` + `_pdf_facts.json` into 8-15K-char Markdown briefing. MUST include:
    
    - Brand mission
    - Top 3 services/products
    - Key stats (with citations per `rules/citations.md`)
    - Founder quote when available
    - 3-5 customer pain points
    - Competitive differentiator
    
    Saved to `_podcast_source.md` for human review + future re-runs.
    
    ### Voice selection
    
    Default voice IDs from `~/.claude/.env`:
    
    - `ELEVENLABS_VOICE_HOST_A=21m00Tcm4TlvDq8ikWAM` (Rachel)
    - `ELEVENLABS_VOICE_HOST_B=AZnzlk1XvdvUeBnXmlld` (Domi)
    
    Override per-site via `_brand.json.podcast.voices[]` when client supplies brand voices. NEVER use the same voice for both hosts.
    
    ### ElevenLabs API call
    
    ```ts
    const r = await fetch("https://api.elevenlabs.io/v1/studio/podcasts", {
      method: "POST",
      headers: { "xi-api-key": env.ELEVENLABS_API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({
        model_id: "eleven_turbo_v2_5",
        mode: { type: "conversation", conversation: { host_voice_id: voiceA, guest_voice_id: voiceB } },
        source: { type: "text", text: briefMd },
        duration_scale: "default",  // ~25-35 min from 10K-char source
        callback_url: `${env.PUBLIC_URL}/api/podcast-webhook`
      })
    });
    const { project_id } = await r.json();
    // Poll GET /v1/studio/projects/{project_id} until status=converted, then download audio_url
    ```
    
    ### Output assets per episode
    
    - `episode-NN.mp3` — 96kbps stereo VBR, ≤1MB/min
    - `transcript.json` — ElevenLabs-provided word-timing JSON
    - `transcript.vtt` — derived for `<audio>` track
    - `cover-3000.jpg` — 3000×3000 JPEG (Apple-required), branded via Satori with brand palette + logo + episode title
    - `chapters.json` — auto-extracted from transcript via gpt-4o-mini topical-segmentation pass
    
    ### `/about` page embed (template ships `<PodcastPlayer>`)
    
    ```tsx
    import Plyr from 'plyr';
    import 'plyr/dist/plyr.css';
    export function PodcastPlayer({ src, transcriptUrl, captionsVtt }: Props) {
      const ref = useRef<HTMLAudioElement>(null);
      useEffect(() => {
        if (!ref.current) return;
        const p = new Plyr(ref.current, {
          controls: ['play','progress','current-time','duration','mute','volume','settings','captions','download'],
          settings: ['speed','captions'],
          speed: { selected: 1, options: [0.75, 1, 1.25, 1.5, 2] }
        });
        return () => p.destroy();
      }, []);
      return (
        <figure data-podcast>
          <audio ref={ref} controls preload="metadata" crossOrigin="anonymous">
            <source src={src} type="audio/mpeg" />
            <track kind="captions" src={captionsVtt} srcLang="en" label="English" default />
          </audio>
          <figcaption>Listen to the {brand.name} audio overview · <a href={transcriptUrl}>Read transcript</a></figcaption>
        </figure>
      );
    }
    ```
    
    ### Transcript on-page (mandatory for SEO + accessibility)
    
    Render full transcript below the player as `<details><summary>Full transcript</summary>...</details>`. Use `details.open=true` via JS on desktop ≥1280px.
    
    ### RSS feed `/podcast.xml` (Hono route)
    
    ```ts
    app.get('/podcast.xml', async (c) => {
      const episodes = await c.env.D1.prepare("SELECT * FROM podcast_episodes WHERE published=1 ORDER BY published_at DESC").all();
      const xml = renderRss({
        title: brand.name + " Podcast",
        link: brand.url,
        description: brand.tagline,
        image: `${brand.url}/podcast/cover-3000.jpg`,
        author: brand.founder || brand.name,
        email: brand.contact_email,
        category: brand.podcast_category || "Technology",
        episodes: episodes.results
      });
      c.header('Content-Type', 'application/rss+xml; charset=utf-8');
      c.header('Cache-Control', 'public, max-age=3600');
      return c.body(xml);
    });
    ```
    
    ### RSS template (iTunes + podcast namespace 1.0)
    
    ```xml
    <?xml version="1.0" encoding="UTF-8"?>
    <rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
      <channel>
        <title>{{title}}</title><link>{{link}}</link><description>{{description}}</description>
        <language>en-us</language><copyright>© {{year}} {{author}}</copyright>
        <itunes:author>{{author}}</itunes:author>
        <itunes:owner><itunes:name>{{author}}</itunes:name><itunes:email>{{email}}</itunes:email></itunes:owner>
        <itunes:image href="{{image}}"/>
        <itunes:category text="{{category}}"/>
        <itunes:explicit>false</itunes:explicit><itunes:type>episodic</itunes:type>
        <podcast:guid>{{channelGuid}}</podcast:guid>
        {{#each episodes}}
        <item>
          <title>{{title}}</title>
          <guid isPermaLink="false">{{rss_guid}}</guid>
          <pubDate>{{rfc822 published_at}}</pubDate>
          <enclosure url="{{audio_url}}" length="{{audio_bytes}}" type="audio/mpeg"/>
          <itunes:duration>{{duration_sec}}</itunes:duration>
          <itunes:episode>{{episode_number}}</itunes:episode>
          <itunes:image href="{{cover_url}}"/>
          <description><![CDATA[{{description_html}}]]></description>
          <content:encoded><![CDATA[{{transcript_html}}]]></content:encoded>
          <podcast:transcript url="{{transcript_url}}" type="application/json"/>
          <podcast:chapters url="{{chapters_url}}" type="application/json+chapters"/>
        </item>
        {{/each}}
      </channel>
    </rss>
    ```
    
    ### Dual JSON-LD on `/about` (mandatory)
    
    ```json
    [
      {"@context":"https://schema.org","@type":"PodcastSeries","name":"<Brand> Podcast","url":"<URL>/about","webFeed":"<URL>/podcast.xml","image":"<URL>/podcast/cover-3000.jpg","author":{"@type":"Person","name":"<Founder>"}},
      {"@context":"https://schema.org","@type":"PodcastEpisode","name":"<Episode Title>","url":"<URL>/podcast/episode-01","datePublished":"<ISO>","duration":"PT47M23S","description":"<150-200 char>","associatedMedia":{"@type":"MediaObject","contentUrl":"<URL>/podcast/episode-01.mp3","encodingFormat":"audio/mpeg"},"partOfSeries":{"@type":"PodcastSeries","name":"<Brand> Podcast","url":"<URL>/about","webFeed":"<URL>/podcast.xml"},"transcript":{"@type":"CreativeWork","text":"<full transcript>","url":"<URL>/podcast/episode-01/transcript"}}
    ]
    ```
    
    ### Submission automation (post-deploy)
    
    First-episode auto-submit to:
    
    - (a) **Apple Podcasts Connect** via `https://podcastsconnect.apple.com/api/v1/podcasts` (`APPLE_PODCAST_KEY_ID` + `APPLE_PODCAST_PRIVATE_KEY` JWT auth — manage at `https://podcastsconnect.apple.com/access`)
    - (b) **Spotify for Podcasters** — no API, manual
    - (c) **YouTube Music** podcast directory (Google Podcasts deprecated 2024)
    - (d) **Podcast Index** `https://podcastindex.org/add` (free, instant)
    - (e) **Amazon Music Podcasters** `https://podcasters.amazon.com`
    
    Document each submission in `_podcast_directories.json` with timestamp + directory URL + status. Brian receives Resend email summary after first episode goes live.
    
    ## Infographic Gallery (≥3 Panels — `/about` Below Podcast)
    
    Three-panel minimum mandatory; scale to 6-9 panels for content-heavy sites.
    
    ### Panel sources (priority order per panel type)
    
    1. **Data chart panel** (mandatory — 1 of 3 minimum) — **Vega-Lite + Puppeteer** SVG render — free, deterministic, version-controlled. Source data from `_research.json.stats[]` + cited per `rules/citations.md`. Vega-Lite spec template:
    
       ```json
       {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","width":1200,"height":675,"background":"#060610","title":{"text":"Impact in Numbers","color":"#FFF","fontSize":42},"data":{"values":[{"category":"Volunteers","value":847},{"category":"Meals Served","value":52000}]},"mark":{"type":"bar","color":"#00E5FF"},"encoding":{"x":{"field":"category","type":"nominal","axis":{"labelColor":"#FFF","titleColor":"#FFF"}},"y":{"field":"value","type":"quantitative","axis":{"labelColor":"#FFF","titleColor":"#FFF"}}}}
       ```
    
       Render via `npx vl2svg spec.json out.svg` then optimize via SVGO. Falls back to `vl2png` for raster sharing previews.
    2. **Process / flow panel** (recommended) — **Recraft v3 API** SVG generation — `POST https://external.api.recraft.ai/v1/images/generations` with `style:"vector_illustration"` + `model:"recraftv3"` + per-slot prompt naming brand palette + composition + topic. ~$0.04/SVG
    3. **Hero illustration panel** (recommended) — **GPT Image 2** (`gpt-image-2` via OpenAI Images API) — ~99% character-level text accuracy in 2026. Per-slot prompt with all 6 mandatory fields (skill 12 SKILL.md "Per-Slot Prompt Mandatory Fields"). ~$0.06/image at 1536×1024
    4. **Napkin AI API** (`api.napkin.ai`) — when both Recraft + GPT-Image saturated; $39/mo unlimited; best for concept-explainer panels (org charts, comparison tables, timelines)
    
    ### Per-panel slot record
    
    Slots into `_notebooklm.json.infographic.panels[]`. Uses identical 6-field prompt structure as GPT Image 1.5 slot manifest (page topic + brand palette + composition + subject specificity + technical specs + negative prompt).
    
    Validator `validate-infographic-on-about.mjs` greps for ≥3 `<svg>` OR `<img>` inside `[data-infographic-gallery]` on `/about`.
    
    ### Render on `/about` (template ships `<InfographicGallery>`)
    
    ```tsx
    export function InfographicGallery({ panels }: Props) {
      return (
        <section data-infographic-gallery aria-label="Visual highlights">
          <h2>By the Numbers</h2>
          <div className="infographic-grid">
            {panels.map((p, i) => (
              <figure key={p.panel_id} data-zoomable data-gallery="infographic" data-caption-title={p.title} data-caption-description={p.description}>
                {p.svg_url ? <object type="image/svg+xml" data={p.svg_url} aria-label={p.title} /> : <img src={p.png_url} alt={p.title} loading={i===0 ? "eager" : "lazy"} />}
                <figcaption>{p.caption}</figcaption>
              </figure>
            ))}
          </div>
        </section>
      );
    }
    ```
    
    Lightbox grouping inherits `data-gallery="infographic"` per always.md "Every multi-image section" rule. Captions mandatory.
    
    ## Explainer Video — Homepage BTF (Second Screen)
    
    ### Provider order
    
    1. **HeyGen API** (`https://api.heygen.com/v2/video/generate`) — production winner for talking-head. ~$1/min standard, $4/min Avatar IV 1080p. 60-90s target ($1.50-$6/site). Docs: `https://docs.heygen.com`
    2. **Synthesia API** (`https://api.synthesia.io/v2/videos`) — fallback, ~$0.80-1.20/min; 140+ avatars + 120+ languages
    3. **Tavus API** ($59/mo + per-min credits) — when client has founder photo + voice samples for personalized digital twin; best for high-touch B2B SaaS
    4. **Veo 3.1 Fast** ($0.15/sec, 8 sec/clip) — fallback to CINEMATIC HERO LOOP when talking-head budget exceeded
    5. **Sora 2** ($0.10/sec 720p, 25s hard cap) — deprecates Sept 24, 2026; evaluate replacements (likely Sora 3)
    
    ### Script generation (Phase 0 step 2c)
    
    gpt-4o synthesizes 75-second script from `_podcast_source.md`:
    
    - Hook (10s), Problem (15s), Solution (30s), Proof (10s), CTA (10s)
    
    Saved to `_video_script.md`. Script feeds HeyGen `script` field directly.
    
    ### HeyGen API call
    
    ```ts
    const r = await fetch("https://api.heygen.com/v2/video/generate", {
      method: "POST",
      headers: { "X-Api-Key": env.HEYGEN_API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({
        video_inputs: [{
          character: { type: "avatar", avatar_id: env.HEYGEN_AVATAR_ID || "Daisy-inskirt-20220818", scale: 1.0, avatar_style: "normal" },
          voice: { type: "text", input_text: scriptMd, voice_id: env.HEYGEN_VOICE_ID || "1bd001e7e50f421d891986aad5158bc8" },
          background: { type: "color", value: brand.colors.primary }
        }],
        dimension: { width: 1920, height: 1080 },
        aspect_ratio: "16:9",
        callback_id: siteId
      })
    });
    const { video_id } = await r.json();
    // Poll GET /v1/video_status.get?video_id={video_id} until status=completed
    // Download video_url + caption_url
    ```
    
    ### Cloudflare Stream upload + embed
    
    ```ts
    // Server-side upload
    const upload = await fetch(`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/stream/copy`, {
      method: "POST",
      headers: { Authorization: `Bearer ${env.CF_STREAM_TOKEN}`, "Content-Type": "application/json" },
      body: JSON.stringify({ url: heygenVideoUrl, meta: { name: `${brand.name} Explainer` }, requireSignedURLs: false, allowedOrigins: [brand.host] })
    });
    const { result: { uid } } = await upload.json();
    // Save uid to _notebooklm.json.explainer_video.cf_stream_uid
    ```
    
    ```tsx
    // Homepage BTF (template ships <ExplainerVideo>)
    export function ExplainerVideo({ uid, posterUrl, captionsVtt }: Props) {
      return (
        <section data-section="explainer-btf" aria-label="Product explainer video">
          <h2>See it in 75 seconds</h2>
          <figure data-video-explainer>
            <stream
              src={uid}
              controls
              preload="metadata"
              poster={posterUrl}
              primary-color="#00E5FF"
              defaultTextTrack="en"
            ></stream>
            <figcaption>{video.title} · <a href={`/video/${uid}/transcript`}>Read transcript</a></figcaption>
          </figure>
        </section>
      );
    }
    // In root index.html: <script src="https://embed.cloudflarestream.com/embed/sdk.latest.js"></script>
    ```
    
    **BTF placement = second screen** (immediately after hero, BEFORE features/services). Validator `validate-explainer-video-btf.mjs` asserts `[data-section="explainer-btf"]` is the 2nd `<section>` in `<main>` on `/`. Hero loop video does NOT count — it's hero, not BTF.
    
    ### JSON-LD `VideoObject` (mandatory on `/`)
    
    ```json
    {
      "@context":"https://schema.org",
      "@type":"VideoObject",
      "name":"<Brand> Explainer",
      "description":"<150-200 char quotable answer block>",
      "thumbnailUrl":"<R2>/video/poster.jpg",
      "contentUrl":"https://customer-<UID>.cloudflarestream.com/<UID>/downloads/default.mp4",
      "embedUrl":"https://iframe.videodelivery.net/<UID>",
      "uploadDate":"<ISO>",
      "duration":"PT1M15S",
      "transcript":"<full transcript text>",
      "hasPart": [
        {"@type":"Clip","name":"Hook","startOffset":0,"endOffset":10,"url":"...#t=0"},
        {"@type":"Clip","name":"Problem","startOffset":10,"endOffset":25,"url":"...#t=10"},
        {"@type":"Clip","name":"Solution","startOffset":25,"endOffset":55,"url":"...#t=25"},
        {"@type":"Clip","name":"Proof","startOffset":55,"endOffset":65,"url":"...#t=55"},
        {"@type":"Clip","name":"CTA","startOffset":65,"endOffset":75,"url":"...#t=65"}
      ]
    }
    ```
    
    ### Video description (the third NotebookLM artifact)
    
    `_notebooklm.json.video_description` block becomes:
    
    - (a) Cloudflare Stream `meta.name` + `meta.description`
    - (b) YouTube/Vimeo upload metadata when cross-posted
    - (c) `description` + `transcript` fields in `VideoObject` JSON-LD
    - (d) `<figcaption>` + collapsed `<details>` transcript on `/` page
    
    Description paragraph 1 MUST be a 40-60 word quotable answer block per `rules/copy-writing.md` "GEO/AI search".
    
    ## Phase 0 Integration (Where the Pipeline Hooks In)
    
    1. **Step 1** — enumerate Media Slot Manifest (`_media_slots.json`) per skill 15 media-acquisition
    2. **Step 2** — enumerate NotebookLM Manifest (`_notebooklm.json`) in parallel — same Phase 0 pre-build batch
    3. **Step 3** (parallel batch) — kick off ElevenLabs Studio podcast (~3 min wait), HeyGen video (~5 min wait), Vega-Lite/Recraft/GPT-Image-2 infographic panels (~30 sec each); webhooks update `_notebooklm.json` filled fields when artifacts complete
    4. **Step 4** — while artifacts cook, Phase 1 page builders proceed — `/about` + `/` reference `_notebooklm.json` URLs (placeholder loaders if not yet filled, swapped in at deploy time)
    5. **Step 5** — pre-deploy gate waits for ALL `_notebooklm.json` artifacts OR exhausted-with-warning state; never block infinitely
    
    ## Cost Tracking + Budget Ceiling
    
    `_notebooklm_daily.json` schema:
    
    ```json
    {"date":"2026-05-02","spend_usd":47.20,"sites":{"site-id-1":{"podcast":0.30,"infographic":0.18,"video":1.50,"hosting":0.05}}}
    ```
    
    Daily ceiling `NOTEBOOKLM_DAILY_BUDGET` (default $300 = ~100 sites). Exhaustion fallbacks:
    
    - **Video** → Veo 3.1 Fast 8s loop ($1.20 → $0.80)
    - **Infographic** → Vega-Lite only (free)
    - **Podcast** → defer to next day, skip with warning
    
    NEVER block site deploy on NotebookLM artifacts.
    
    ## API Keys Required
    
    - `ELEVENLABS_API_KEY` — `https://elevenlabs.io/app/settings/api-keys`
    - `HEYGEN_API_KEY` — `https://app.heygen.com/settings/api`
    - `HEYGEN_AVATAR_ID`, `HEYGEN_VOICE_ID` — `https://app.heygen.com/avatars` + `https://app.heygen.com/voices`
    - `SYNTHESIA_API_KEY` (fallback) — `https://app.synthesia.io/account/integrations`
    - `TAVUS_API_KEY` (premium) — `https://platform.tavus.io/api-keys`
    - `RECRAFT_API_KEY` — `https://www.recraft.ai/profile/api`
    - `OPENAI_API_KEY` — already present; gpt-image-2 + gpt-4o for scripts
    - `CF_STREAM_TOKEN` + `CF_ACCOUNT_ID` — already present in build pipeline
    - `APPLE_PODCAST_KEY_ID` + `APPLE_PODCAST_PRIVATE_KEY` — Apple Podcasts Connect; generate at `https://podcastsconnect.apple.com/access`
    - `AUTOCONTENT_API_KEY` (fallback) — `https://autocontentapi.com/account`
    - `NAPKIN_API_KEY` (panel fallback) — `https://www.napkin.ai/account/api`
    
    All loaded via `get-secret KEY` or sourced from `${CLAUDE_ENV_FILE}` per CLAUDE.md secrets pattern.
    
    ## Quality Gates (cross-ref skill 15 quality-gates.md)
    
    - `validate-podcast-on-about.mjs` — asserts `/about` HTML contains `<audio>` with `[src*=".mp3"]` AND PodcastSeries + PodcastEpisode JSON-LD AND inline transcript text ≥500 chars. Failures: `podcast.missing` | `podcast.no_jsonld` | `podcast.no_transcript`
    - `validate-infographic-on-about.mjs` — asserts `[data-infographic-gallery]` on `/about` contains ≥3 `<svg>|<object[type="image/svg+xml"]>|<img>` children, each with caption attrs. Failures: `infographic.missing` | `infographic.fewer_than_three` | `infographic.caption_missing`
    - `validate-explainer-video-btf.mjs` — asserts `[data-section="explainer-btf"]` is 2nd `<section>` of `<main>` on `/` AND contains `<stream>` element with valid CF Stream UID AND VideoObject JSON-LD with `hasPart` chapters array. Failures: `video.missing` | `video.not_btf` | `video.no_jsonld` | `video.no_chapters`
    - `validate-podcast-rss.mjs` — asserts `/podcast.xml` returns 200 with valid RSS 2.0 + iTunes namespace + ≥1 `<item>` with `<enclosure type="audio/mpeg">`. Failures: `rss.missing` | `rss.invalid` | `rss.no_episodes`
    
    All four wired into `build_validators.ts` between R2 upload and `published` status. Initial deploy in `report` mode, flip to `strict` once template ships clean.
    
  • og-card-pipeline.md 19.5 KB
    ---
    skill: og-card-pipeline
    version: 1.0.0
    tags: [cloudflare, workers, og, opengraph, satori, r2, media]
    cross-links: [cf-browser-rendering, always, r2-patterns]
    ---
    
    # OG Card Pipeline
    
    Advanced companion to `og-image-generation.md`. Focuses on: content-hash URLs for immutable caching, per-entity typed JSX templates, module-scoped font memoization, and R2 as the sole cache layer. Skip this file for simple query-param cards — use the base file. Use this when you need per-route typed cards (blog, product, profile, event) with scraper-proof cache invalidation.
    
    ## Why OG Cards Matter
    
    - Twitter/X, LinkedIn, Slack, iMessage all render OG cards on link share — blank card = invisible on social
    - `1200×630` is the canonical size; render at exactly that — no upscaling needed, 2× retina handled by display
    - `og:image` must be an absolute HTTPS URL; relative URLs silently fail in most scrapers
    - Stale OG images linger 7–30 days in scraper caches — content-hash URLs force a fresh fetch on every content change
    - `summary_large_image` Twitter card type always beats `summary` — never use `summary` for visual content
    - LinkedIn's scraper caches more aggressively than Twitter — path-based hashes are more reliable than `?v=` params
    
    ## Stack Choice
    
    - **Satori** (Vercel): JSX object tree → SVG; runs pure WASM in Workers — no headless browser, no cold-start penalty from Puppeteer
    - **Resvg-wasm**: SVG → PNG in Workers; ~2MB WASM binary — lazy-load from R2 on first request, then module-scope cache
    - **R2**: sole storage layer for WASM, fonts, and rendered PNGs — no KV needed, immutable URLs make TTLs irrelevant
    - **CF Browser Rendering** (`@cloudflare/puppeteer`): fallback only when Satori can't handle a design (CSS `backdrop-filter`, SVG `feBlend`); costs 1 session/render, ~2–5s — reserve for <5% of cards
    - Never use `canvas` in Workers — no native canvas API in the V8 isolate
    
    ## Install + wrangler.toml
    
    ```bash
    npm i satori @resvg/resvg-wasm
    npm i -D @types/react
    ```
    
    ```toml
    # wrangler.toml
    [[r2_buckets]]
    binding = "OG_BUCKET"
    bucket_name = "og-cards"
    
    # Optional: dedicated DB binding if card props come from D1
    [[d1_databases]]
    binding = "DB"
    database_name = "main"
    database_id = "your-d1-id"
    ```
    
    ## One-Time R2 Asset Upload
    
    ```bash
    # Fonts — TTF only; OTF has partial Satori support (some features silently dropped)
    wrangler r2 object put og-cards/fonts/Sora-Bold.ttf --file ./assets/fonts/Sora-Bold.ttf
    wrangler r2 object put og-cards/fonts/Sora-Regular.ttf --file ./assets/fonts/Sora-Regular.ttf
    
    # Resvg WASM — path must match ensureResvg() below
    wrangler r2 object put og-cards/wasm/resvg.wasm \
      --file ./node_modules/@resvg/resvg-wasm/index_bg.wasm
    ```
    
    - Re-upload fonts when upgrading Satori major versions — glyph subset requirements change
    - Pin `@resvg/resvg-wasm` version in `package.json`; WASM binary must match the JS wrapper exactly
    
    ## WASM + Font Bootstrap
    
    ```ts
    // src/og/bootstrap.ts
    import initResvg, { Resvg } from '@resvg/resvg-wasm';
    export { Resvg };
    
    // Module-scope singletons — survive across requests in the same isolate
    let resvgReady = false;
    let fontsCache: { soraBold: ArrayBuffer; soraRegular: ArrayBuffer } | null = null;
    
    export async function ensureResvg(bucket: R2Bucket): Promise<void> {
      if (resvgReady) return;
      const obj = await bucket.get('wasm/resvg.wasm');
      if (!obj) throw new Error('resvg.wasm missing from R2 — run upload script');
      await initResvg(await obj.arrayBuffer());
      resvgReady = true;
    }
    
    export async function getFonts(
      bucket: R2Bucket
    ): Promise<{ soraBold: ArrayBuffer; soraRegular: ArrayBuffer }> {
      if (fontsCache) return fontsCache;
      const [bold, regular] = await Promise.all([
        bucket.get('fonts/Sora-Bold.ttf'),
        bucket.get('fonts/Sora-Regular.ttf'),
      ]);
      if (!bold || !regular) throw new Error('Fonts missing from R2');
      fontsCache = {
        soraBold: await bold.arrayBuffer(),
        soraRegular: await regular.arrayBuffer(),
      };
      return fontsCache;
    }
    ```
    
    - First request: WASM init + font fetch from R2 (~300–500ms overhead); subsequent requests in same isolate: 0ms
    - Isolates are reused across requests in the same CF PoP — module-scope caching is safe and effective
    - Never store WASM/fonts in Workers KV for this — R2 streaming avoids KV's 25MB value limit
    
    ## Content-Hash Caching
    
    ```ts
    // src/og/cache.ts
    export function hashProps(props: unknown): string {
      // TextEncoder + crypto.subtle — available in Workers (no Node import needed)
      const json = JSON.stringify(props);
      // Synchronous hex hash via a simple djb2 for speed; use subtle.digest for crypto-grade
      let h = 5381;
      for (let i = 0; i < json.length; i++) h = ((h << 5) + h) ^ json.charCodeAt(i);
      return (h >>> 0).toString(16).padStart(8, '0');
    }
    
    export function r2Key(type: string, hash: string): string {
      return `cards/${type}/${hash}.png`;
    }
    
    export async function getCachedPng(
      bucket: R2Bucket,
      key: string
    ): Promise<Response | null> {
      const obj = await bucket.get(key);
      if (!obj) return null;
      return new Response(obj.body, {
        headers: {
          'Content-Type': 'image/png',
          'Cache-Control': 'public, max-age=31536000, immutable',
          'X-Cache': 'HIT',
        },
      });
    }
    
    export async function storePng(
      bucket: R2Bucket,
      key: string,
      png: Uint8Array
    ): Promise<void> {
      await bucket.put(key, png, {
        httpMetadata: {
          contentType: 'image/png',
          cacheControl: 'public, max-age=31536000',
        },
        customMetadata: { generatedAt: new Date().toISOString() },
      });
    }
    ```
    
    - `immutable` directive tells CDN edges and browsers the bytes will never change at this URL — safe because the URL encodes the content hash
    - Content changes → new hash → new R2 key → scraper fetches fresh — no manual cache purge needed
    - Stale keys accumulate; run a monthly Scheduled Worker to delete R2 objects older than 90 days with `bucket.list()` + `bucket.delete()`
    
    ## SVG → PNG Conversion
    
    ```ts
    // src/og/render.ts
    import { Resvg, ensureResvg } from './bootstrap';
    
    export async function svgToPng(svg: string, bucket: R2Bucket): Promise<Uint8Array> {
      await ensureResvg(bucket);
      const resvg = new Resvg(svg, {
        fitTo: { mode: 'width', value: 1200 },
        font: { loadSystemFonts: false }, // only embedded fonts — Workers has no system fonts
      });
      return resvg.render().asPng();
    }
    ```
    
    ## Template: Blog Post Card
    
    ```ts
    // src/og/templates/blog.ts
    import satori from 'satori';
    
    export interface BlogCardProps {
      title: string;       // ≤80 chars for clean render; truncate at 80 before passing
      author: string;
      date: string;        // pre-formatted: 'Jun 18, 2026'
      category: string;
      logoDataUrl: string; // base64 PNG — Satori can't fetch external URLs at render time
    }
    
    export async function renderBlogCard(
      props: BlogCardProps,
      fonts: { soraBold: ArrayBuffer; soraRegular: ArrayBuffer }
    ): Promise<string> {
      return satori(
        {
          type: 'div',
          props: {
            style: {
              width: 1200, height: 630,
              display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
              backgroundColor: '#060610',
              padding: '60px 80px',
              fontFamily: 'Sora',
            },
            children: [
              // Accent bar
              { type: 'div', props: { style: { width: 64, height: 4, backgroundColor: '#00E5FF', marginBottom: 24 } } },
              // Category chip
              {
                type: 'div',
                props: {
                  style: {
                    backgroundColor: '#00E5FF1A', color: '#00E5FF',
                    border: '1px solid #00E5FF33', borderRadius: 6,
                    padding: '6px 16px', fontSize: 14, width: 'fit-content',
                  },
                  children: props.category,
                },
              },
              // Title — Satori wraps text automatically; keep ≤80 chars for 2-line max at fontSize 52
              {
                type: 'div',
                props: {
                  style: {
                    color: '#FFFFFF', fontSize: 52, fontWeight: 700,
                    lineHeight: 1.15, maxWidth: 900, marginTop: 24,
                  },
                  children: props.title,
                },
              },
              // Footer
              {
                type: 'div',
                props: {
                  style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
                  children: [
                    {
                      type: 'div',
                      props: {
                        style: { color: '#7C3AED', fontSize: 18, fontWeight: 400 },
                        children: `${props.author} · ${props.date}`,
                      },
                    },
                    {
                      type: 'img',
                      props: {
                        src: props.logoDataUrl,
                        width: 120, height: 36,
                        style: { objectFit: 'contain' },
                      },
                    },
                  ],
                },
              },
            ],
          },
        },
        {
          width: 1200, height: 630,
          fonts: [
            { name: 'Sora', data: fonts.soraBold, weight: 700, style: 'normal' },
            { name: 'Sora', data: fonts.soraRegular, weight: 400, style: 'normal' },
          ],
        }
      );
    }
    ```
    
    - Satori does NOT support `gap`, `fit-content` (use explicit widths), `text-overflow: ellipsis`, or CSS variables — check supported properties at `github.com/vercel/satori#css`
    - External image URLs at render time fail silently — convert logo to base64 data URL once at startup and cache in module scope
    - `lineHeight` in Satori is unitless (1.15), not `px` or `em`
    
    ## Template: Product / Landing Card
    
    ```ts
    // src/og/templates/product.ts
    import satori from 'satori';
    
    export interface ProductCardProps {
      tagline: string;    // hero line, ≤60 chars
      subheadline: string; // ≤120 chars
      logoDataUrl: string;
      accentColor?: string; // defaults to '#00E5FF'
    }
    
    export async function renderProductCard(
      props: ProductCardProps,
      fonts: { soraBold: ArrayBuffer; soraRegular: ArrayBuffer }
    ): Promise<string> {
      const accent = props.accentColor ?? '#00E5FF';
      return satori(
        {
          type: 'div',
          props: {
            style: {
              width: 1200, height: 630,
              display: 'flex', flexDirection: 'column', justifyContent: 'center',
              // Satori supports linear-gradient via backgroundImage
              backgroundImage: 'linear-gradient(135deg, #060610 0%, #0d0d2b 100%)',
              padding: '80px 100px',
              fontFamily: 'Sora',
              position: 'relative',
            },
            children: [
              // Top accent bar (full width)
              {
                type: 'div',
                props: {
                  style: {
                    position: 'absolute', top: 0, left: 0,
                    width: 1200, height: 4, backgroundColor: accent,
                  },
                },
              },
              // Logo top-left
              {
                type: 'img',
                props: {
                  src: props.logoDataUrl,
                  width: 100, height: 30,
                  style: { objectFit: 'contain', marginBottom: 48 },
                },
              },
              // Tagline
              {
                type: 'div',
                props: {
                  style: { color: '#FFFFFF', fontSize: 64, fontWeight: 700, lineHeight: 1.1, marginBottom: 24 },
                  children: props.tagline,
                },
              },
              // Subheadline
              {
                type: 'div',
                props: {
                  style: { color: '#A0A0C0', fontSize: 24, fontWeight: 400, lineHeight: 1.5, maxWidth: 800 },
                  children: props.subheadline,
                },
              },
              // Bottom CTA pill
              {
                type: 'div',
                props: {
                  style: {
                    position: 'absolute', bottom: 60, right: 100,
                    backgroundColor: accent, borderRadius: 8,
                    padding: '12px 28px', fontSize: 18, fontWeight: 700, color: '#060610',
                  },
                  children: 'megabyte.space',
                },
              },
            ],
          },
        },
        {
          width: 1200, height: 630,
          fonts: [
            { name: 'Sora', data: fonts.soraBold, weight: 700, style: 'normal' },
            { name: 'Sora', data: fonts.soraRegular, weight: 400, style: 'normal' },
          ],
        }
      );
    }
    ```
    
    ## Logo Preload (base64 data URL)
    
    ```ts
    // src/og/logo.ts — call once at isolate startup, cache in module scope
    let logoCache: string | null = null;
    
    export async function getLogoDataUrl(bucket: R2Bucket): Promise<string> {
      if (logoCache) return logoCache;
      const obj = await bucket.get('brand/logo.png');
      if (!obj) throw new Error('brand/logo.png missing from R2');
      const buf = await obj.arrayBuffer();
      const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
      logoCache = `data:image/png;base64,${b64}`;
      return logoCache;
    }
    ```
    
    - `btoa` + `Uint8Array` works in Workers without Buffer — no Node import needed
    - Keep logo PNG under 50KB; larger logos inflate every OG render's memory footprint
    
    ## Hono Route (complete)
    
    ```ts
    // src/routes/og.ts
    import { Hono } from 'hono';
    import { getFonts, ensureResvg } from '../og/bootstrap';
    import { svgToPng } from '../og/render';
    import { hashProps, r2Key, getCachedPng, storePng } from '../og/cache';
    import { renderBlogCard } from '../og/templates/blog';
    import { renderProductCard } from '../og/templates/product';
    import { getLogoDataUrl } from '../og/logo';
    
    const og = new Hono<{ Bindings: Env }>();
    
    og.get('/og/:type/:slug', async (c) => {
      const { type, slug } = c.req.param();
      const bucket = c.env.OG_BUCKET;
    
      // parallel bootstrap — skips on warm isolate
      const [fonts, logoDataUrl] = await Promise.all([
        getFonts(bucket),
        getLogoDataUrl(bucket),
      ]);
    
      let props: Record<string, unknown> | null = null;
      let svg: string | null = null;
    
      if (type === 'blog') {
        const post = await c.env.DB
          .prepare('SELECT title, author_name, published_at, category FROM blog_posts WHERE slug = ? AND published = 1')
          .bind(slug)
          .first<{ title: string; author_name: string; published_at: number; category: string }>();
        if (!post) return c.notFound();
        props = {
          title: post.title.slice(0, 80),
          author: post.author_name,
          date: new Date(post.published_at * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
          category: post.category,
          logoDataUrl,
        };
        const hash = hashProps(props);
        const key = r2Key('blog', hash);
        const cached = await getCachedPng(bucket, key);
        if (cached) return cached;
        svg = await renderBlogCard(props as any, fonts);
        const png = await svgToPng(svg, bucket);
        await storePng(bucket, key, png);
        return new Response(png, {
          headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable', 'X-Cache': 'MISS' },
        });
      }
    
      if (type === 'product') {
        // product cards are static per slug — slug maps to a config object
        const configs: Record<string, { tagline: string; subheadline: string }> = {
          default: { tagline: 'Ship faster with AI.', subheadline: 'Cloudflare-native tools for solo builders who move at agent speed.' },
        };
        const config = configs[slug] ?? configs['default'];
        props = { ...config, logoDataUrl };
        const hash = hashProps(props);
        const key = r2Key('product', hash);
        const cached = await getCachedPng(bucket, key);
        if (cached) return cached;
        svg = await renderProductCard(props as any, fonts);
        const png = await svgToPng(svg, bucket);
        await storePng(bucket, key, png);
        return new Response(png, {
          headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable', 'X-Cache': 'MISS' },
        });
      }
    
      return c.notFound();
    });
    
    export { og };
    ```
    
    ## Meta Tags in HTML
    
    ```html
    <!-- Blog post page — absolute URL, path-based hash baked in at SSR/SSG time -->
    <meta property="og:image" content="https://yourdomain.com/og/blog/my-post-slug" />
    <meta property="og:image:width" content="1200" />
    <meta property="og:image:height" content="630" />
    <meta property="og:image:type" content="image/png" />
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:image" content="https://yourdomain.com/og/blog/my-post-slug" />
    ```
    
    - Never `summary` — always `summary_large_image`
    - LinkedIn re-scrapes on `?v={hash}` param change if you can't use path-based invalidation
    - Validate with Twitter Card Validator (`cards.twitter.com/validator`) and Facebook Sharing Debugger after deploy
    
    ## CF Browser Rendering Fallback (rare)
    
    ```ts
    // Only when Satori cannot handle the design — e.g. CSS backdrop-filter, feBlend
    import puppeteer from '@cloudflare/puppeteer';
    
    export async function renderWithBrowser(
      env: { MYBROWSER: Fetcher; OG_BUCKET: R2Bucket },
      screenshotUrl: string,
      cacheKey: string
    ): Promise<Uint8Array> {
      const browser = await puppeteer.launch(env.MYBROWSER);
      const page = await browser.newPage();
      await page.setViewport({ width: 1200, height: 630 });
      await page.goto(screenshotUrl, { waitUntil: 'networkidle0' });
      const png = await page.screenshot({ clip: { x: 0, y: 0, width: 1200, height: 630 } }) as Uint8Array;
      await browser.close();
      await storePng(env.OG_BUCKET, cacheKey, png);
      return png;
    }
    ```
    
    - Add `browser = { binding = "MYBROWSER" }` to `wrangler.toml` and enable CF Browser Rendering in dashboard
    - Each call costs one browser session from your plan quota — cache aggressively
    
    ## R2 Lifecycle: Monthly Cleanup Cron
    
    ```ts
    // src/scheduled/og-cleanup.ts — wire to `scheduled` export in worker entry
    export async function cleanOgCache(bucket: R2Bucket): Promise<void> {
      const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000; // 90 days
      let cursor: string | undefined;
      do {
        const list = await bucket.list({ prefix: 'cards/', cursor, limit: 1000 });
        const toDelete = list.objects
          .filter((o) => new Date(o.uploaded).getTime() < cutoff)
          .map((o) => o.key);
        await Promise.all(toDelete.map((k) => bucket.delete(k)));
        cursor = list.truncated ? list.cursor : undefined;
      } while (cursor);
    }
    ```
    
    ```toml
    # wrangler.toml
    [triggers]
    crons = ["0 3 1 * *"] # 03:00 UTC on the 1st of each month
    ```
    
    ## Satori Gotchas (production-verified)
    
    - `gap` is not supported — use `padding` + fixed widths for spacing between flex children
    - `fit-content` in width/height not supported — set explicit pixel dimensions
    - `text-overflow: ellipsis` not supported — truncate strings in JS before passing to Satori
    - CSS variables (`var(--color)`) not supported — inline all values
    - `position: absolute` children require `position: relative` on parent — works correctly in Satori
    - External image `src` at render time fails silently — always preload as base64 data URL
    - OTF fonts may drop features (ligatures, kerning) — stick to TTF for predictable rendering
    - WASM cold start: first request ~500–800ms; warm isolate <100ms — acceptable for OG (scrapers don't hot-path)
    
    ## Production Checklist
    
    - `wrangler r2 object put` fonts + WASM before first deploy
    - Validate card render locally: `wrangler dev` → `curl http://localhost:8787/og/blog/test-slug > /tmp/test.png && open /tmp/test.png`
    - Run Twitter Card Validator and Facebook Sharing Debugger post-deploy
    - Confirm `X-Cache: HIT` on second request to verify R2 cache is working
    - Set R2 lifecycle cron or bucket expiry rule for `cards/` prefix — unchecked growth is the only real risk
    - Monitor R2 storage in CF dashboard — 1000 cards × 150KB avg = 150MB, well within free tier
    
    ## See Also
    
    - `[[cf-browser-rendering]]` — when Satori cannot handle complex CSS; Puppeteer session setup
    - `[[always]]` — OG image is a hard gate on every page before ship
    - `[[r2-patterns]]` — R2 lifecycle rules, presigned URLs, multipart upload patterns
    - `og-image-generation.md` (this dir) — simpler query-param based cards; use when you don't need typed per-entity templates
    
  • og-image-generation.md 8.5 KB
    ---
    name: "OG Image Generation"
    description: "Satori (@vercel/og) for edge-rendered OG images from HTML/JSX templates. CF Worker route /api/og?title=X&desc=Y → Satori renders HTML to SVG → resvg converts to PNG → cache in KV/R2. Dark brand template (1200x630), dynamic per-route generation, social platform sizes, meta-tag helper integration."
    updated: "2026-04-23"
    ---
    
    # OG Image Generation
    
    ## Worker Route (Hono)
    
    ```typescript
    // src/routes/og.ts
    import { Hono } from 'hono';
    import { zValidator } from '@hono/zod-validator';
    import { z } from 'zod';
    import satori from 'satori';
    import { Resvg } from '@resvg/resvg-wasm';
    
    const og = new Hono<{ Bindings: Env }>();
    
    const ogSchema = z.object({
      title: z.string().min(1).max(120),
      desc: z.string().max(200).optional(),
      size: z.enum(['og', 'twitter', 'linkedin']).default('og'),
    });
    
    const SIZES: Record<string, { width: number; height: number }> = {
      og: { width: 1200, height: 630 },
      twitter: { width: 1200, height: 600 },
      linkedin: { width: 1200, height: 627 },
    };
    
    og.get('/og', zValidator('query', ogSchema), async (c) => {
      const { title, desc, size } = c.req.valid('query');
      const cacheKey = `og:${size}:${encodeURIComponent(title)}:${encodeURIComponent(desc || '')}`;
    
      // Check KV cache first
      const cached = await c.env.KV.get(cacheKey, 'arrayBuffer');
      if (cached) {
        return new Response(cached, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=604800' } });
      }
    
      try {
        const dimensions = SIZES[size];
        const soraFont = await c.env.R2.get('fonts/Sora-Bold.ttf');
        const spaceFont = await c.env.R2.get('fonts/SpaceGrotesk-Regular.ttf');
        if (!soraFont || !spaceFont) throw new Error('Font files missing from R2');
    
        const svg = await satori(
          {
            type: 'div',
            props: {
              style: {
                width: '100%',
                height: '100%',
                display: 'flex',
                flexDirection: 'column',
                justifyContent: 'center',
                padding: '60px 80px',
                background: '#060610',
                fontFamily: 'Space Grotesk',
              },
              children: [
                {
                  type: 'div',
                  props: {
                    style: { width: 64, height: 4, background: '#00E5FF', marginBottom: 24 },
                  },
                },
                {
                  type: 'div',
                  props: {
                    style: { fontSize: 56, fontWeight: 700, color: '#FFFFFF', fontFamily: 'Sora', lineHeight: 1.2, marginBottom: 16 },
                    children: title,
                  },
                },
                desc
                  ? {
                      type: 'div',
                      props: {
                        style: { fontSize: 24, color: '#A0A0B8', lineHeight: 1.5 },
                        children: desc,
                      },
                    }
                  : null,
                {
                  type: 'div',
                  props: {
                    style: { position: 'absolute', bottom: 40, right: 80, fontSize: 20, color: '#00E5FF' },
                    children: 'megabyte.space',
                  },
                },
              ].filter(Boolean),
            },
          },
          {
            ...dimensions,
            fonts: [
              { name: 'Sora', data: await soraFont.arrayBuffer(), weight: 700, style: 'normal' },
              { name: 'Space Grotesk', data: await spaceFont.arrayBuffer(), weight: 400, style: 'normal' },
            ],
          }
        );
    
        const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: dimensions.width } });
        const png = resvg.render().asPng();
    
        // Cache in KV (7-day TTL)
        await c.env.KV.put(cacheKey, png, { expirationTtl: 604800 });
    
        // Store in R2 for long-term backup
        const r2Key = `og/${cacheKey.replace(/:/g, '/')}.png`;
        await c.env.R2.put(r2Key, png, { httpMetadata: { contentType: 'image/png' } });
    
        return new Response(png, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=604800' } });
      } catch (error) {
        // Fallback: serve pre-generated default from R2
        const fallback = await c.env.R2.get('og/default.png');
        if (fallback) {
          return new Response(await fallback.arrayBuffer(), {
            headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' },
          });
        }
        return c.json({ error: 'OG generation failed', code: 'OG_RENDER_ERROR' }, 500);
      }
    });
    
    export { og };
    ```
    
    ## Meta-Tag Helper
    
    ```typescript
    // src/lib/og-meta.ts
    interface OgMetaOptions {
      title: string;
      description: string;
      path: string;
      size?: 'og' | 'twitter' | 'linkedin';
    }
    
    function ogImageUrl(baseUrl: string, opts: OgMetaOptions): string {
      const params = new URLSearchParams({ title: opts.title, desc: opts.description });
      if (opts.size) params.set('size', opts.size);
      return `${baseUrl}/api/og?${params}`;
    }
    
    function generateOgMeta(baseUrl: string, opts: OgMetaOptions): string {
      const ogUrl = ogImageUrl(baseUrl, opts);
      const twitterUrl = ogImageUrl(baseUrl, { ...opts, size: 'twitter' });
      const canonical = `${baseUrl}${opts.path}`;
    
      return `
        <meta property="og:title" content="${opts.title}" />
        <meta property="og:description" content="${opts.description}" />
        <meta property="og:image" content="${ogUrl}" />
        <meta property="og:image:width" content="1200" />
        <meta property="og:image:height" content="630" />
        <meta property="og:url" content="${canonical}" />
        <meta property="og:type" content="website" />
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content="${opts.title}" />
        <meta name="twitter:description" content="${opts.description}" />
        <meta name="twitter:image" content="${twitterUrl}" />
      `.trim();
    }
    
    export { generateOgMeta, ogImageUrl };
    ```
    
    ## Angular SSR Integration
    
    ```typescript
    // og-meta.service.ts
    import { Injectable, inject } from '@angular/core';
    import { Meta } from '@angular/platform-browser';
    
    @Injectable({ providedIn: 'root' })
    export class OgMetaService {
      private readonly meta = inject(Meta);
    
      setOgTags(title: string, description: string, path: string): void {
        const baseUrl = 'https://megabyte.space';
        const ogUrl = `${baseUrl}/api/og?title=${encodeURIComponent(title)}&desc=${encodeURIComponent(description)}`;
        const twitterUrl = `${baseUrl}/api/og?title=${encodeURIComponent(title)}&desc=${encodeURIComponent(description)}&size=twitter`;
    
        this.meta.updateTag({ property: 'og:title', content: title });
        this.meta.updateTag({ property: 'og:description', content: description });
        this.meta.updateTag({ property: 'og:image', content: ogUrl });
        this.meta.updateTag({ property: 'og:image:width', content: '1200' });
        this.meta.updateTag({ property: 'og:image:height', content: '630' });
        this.meta.updateTag({ property: 'og:url', content: `${baseUrl}${path}` });
        this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
        this.meta.updateTag({ name: 'twitter:image', content: twitterUrl });
      }
    }
    ```
    
    ## Cache Purge on Content Change
    
    ```typescript
    // Inngest function to purge OG cache when content updates
    import { inngest } from './client';
    
    export const purgeOgCache = inngest.createFunction(
      { id: 'purge-og-cache', name: 'Purge OG Image Cache' },
      { event: 'content/updated' },
      async ({ event, step }) => {
        const { path, title } = event.data;
        const cacheKey = `og:og:${encodeURIComponent(title)}:*`;
    
        await step.run('purge-kv', async () => {
          const keys = await KV.list({ prefix: `og:og:${encodeURIComponent(title)}` });
          await Promise.all(keys.keys.map((k) => KV.delete(k.name)));
        });
    
        await step.run('purge-r2', async () => {
          const r2Key = `og/og/${encodeURIComponent(title)}`;
          await R2.delete(r2Key);
        });
      }
    );
    ```
    
    ## R2 Font Setup
    
    ```bash
    # Upload brand fonts to R2 for Satori rendering
    wrangler r2 object put uploads/fonts/Sora-Bold.ttf --file=./assets/fonts/Sora-Bold.ttf
    wrangler r2 object put uploads/fonts/SpaceGrotesk-Regular.ttf --file=./assets/fonts/SpaceGrotesk-Regular.ttf
    ```
    
    ## wrangler.toml Bindings
    
    ```toml
    [[kv_namespaces]]
    binding = "KV"
    id = "your-kv-namespace-id"
    
    [[r2_buckets]]
    binding = "R2"
    bucket_name = "uploads"
    ```
    
    ## Social Platform Sizes
    
    - **OG (Facebook/general)** — 1200x630
    - **Twitter** — 1200x600
    - **LinkedIn** — 1200x627
    
    All use same template, different crop. `size` param on `/api/og` controls which dimensions Satori renders.
    
    ## Fallback Chain
    
    1. KV cache hit → serve immediately
    2. Satori render → cache in KV + R2 → serve
    3. Satori fails → serve R2 `default.png`
    4. R2 default missing → 500 with error envelope
    
    Pre-generate `default.png` at deploy time:
    
    ```bash
    curl /api/og?title=Megabyte+Labs&desc=Ship+faster
    ```
    
  • SKILL.md 6.3 KB
    ---
    name: "media-orchestration"
    description: "Section-by-section media planning and generation. Image generation (GPT Image 1.5 primary, built-in fallback), logo/icon generation (Ideogram v3 → favicon set), video generation (Sora), social preview images (OG 1200x630 + AI search optimization), stock photo curation (Pexels, Pixabay), critique/remix loops (max 3 rounds), asset compression pipeline, and media performance budgets."
    metadata:
      version: "2.1.0"
      updated: "2026-05-03"
      context: "fork"
      effort: "high"
      model: "sonnet"
    license: "Rutgers"
    compatibility:
      claude-code: ">=2.0.0"
      agentskills: ">=1.0.0"
    submodules:
      - 30-ideogram-methods.md
      - build-breaking-rules.md
      - compression-pipeline.md
      - image-optimization.md
      - image-profiling.md
      - lightbox-classifier.md
      - media-prompts.md
      - notebooklm-pipeline.md
      - og-image-generation.md
      - social-brand-hex.md
      - technical-diagramming.md
    priority: 3
    pack: "media"
    stage: stable
    triggers:
      - "image gen"
      - "dalle"
      - "media"
      - "og image"
    paths:
      - "org:website_build"
    ---
    
    # 12 — Media Orchestration
    
    Plan and generate all site media section-by-section: images (GPT Image 1.5), logos (Ideogram v3), video (Sora), OG cards, and compression pipeline.
    
    > **Model migration note (pass-77, 2026-06-09)**: `DALL-E` → **GPT Image 1.5** + `GPT-4o` → **GPT Image 2 vision**. Per `platform.openai.com/docs/deprecations`.
    
    ## Submodules
    
    - **media-prompts** — prompt templates, Ideogram v3 API
    - **compression-pipeline** — Python code, format tables, CF Image Transforms, CLS, broken image detection
    - **og-image-generation** — Satori edge-rendered OG, KV / R2 cache, meta-tag helper
    - **image-optimization** — Sharp processing, responsive srcset, WebP/AVIF, blur placeholders, R2 pipeline
    - **image-profiling** — GPT Image 2 vision batch profiling
    - **lightbox-classifier** — per-image eligibility: `kind!=logo` + ≥1024×768 + score≥7
    - **social-brand-hex** — canonical brand-color map per social platform
    - **notebooklm-pipeline** — per-site podcast via ElevenLabs Studio + infographic via Vega-Lite/Recraft/GPT-Image-2 + HeyGen video + CF Stream + RSS + JSON-LD + cost ceiling $3.50/site
    
    ## Strategy by Section
    
    Hero → GPT Image 1.5 / Sora · Features → GPT Image 1.5 / SVG · How It Works → GPT Image 1.5 · Testimonials → stock · About → stock/real · Blog → GPT Image 1.5 · Social → Satori OG 1200×630 · Icons → Ideogram v3
    
    Pre-gen checklist: communication goal? Brand style? Dimensions? Format? Budget? Stock or generated?
    
    ## Visual Inspection (MANDATORY)
    
    Read every image before deploy. Check: blur, artifacts, watermarks, wrong colors, AI hallucinations, gibberish text. Fail = regenerate w/ improved prompt. Quality bar: 2× retina, no artifacts, brand palette, consistent style, no uncanny valley.
    
    ## Brian's Style
    
    - Space/cosmic — `#00E5FF` + `#7C3AED`, deep black (`#060610`); connections/dots — quantum, neural, constellation
    - "Ultra realistic" scenes; transparent logos; simpler always; motifs — squirrels, turtles
    
    ## Image Generation
    
    - **GPT Image 1.5** preferred (best quality); **GPT Image 1** for speed; **GPT Image 1-mini** for bulk/drafts
    - Fallback: `scripts/image_gen.py`; product screenshots: Playwright on live URL
    - Be specific: include colors, specify avoidances
    
    ## GPT Image 1.5 First Slot-Fill (CANONICAL — UNIVERSAL)
    
    PRIMARY originator for every slot real-entity sources (Places / uploads / scrape) didn't fill. GPT Image 1.5 invoked BEFORE generic stock; stock APIs run parallel speed-pass fallback (instant return if GPT Image 1.5 hangs >15s). See skill 15 `media-acquisition` + Fail-CLOSED auto-regenerate (5 attempts, $0.40 worst-case ceiling per slot).
    
    ## Per-Slot Prompt Mandatory Fields (BUILD-BREAKING — `validate-image-prompts.mjs` + `validate-dalle-slot-fill.mjs`)
    
    Every GPT Image 1.5 call MUST encode 6 fields from `_media_slots.json`:
    
    1. Page topic + intent verbatim from `topic_intent`
    2. Brand palette tokens from `_brand.json.colors` (inline hex)
    3. Composition + aspect ratio matching `aspect`
    4. Subject specificity (NEVER "people" — always "octogenarian volunteer plating soup, soft window light, documentary style")
    5. Photographic technical specs (camera, lens, lighting, DoF — "shot on Hasselblad, 85mm prime, golden hour, shallow DoF")
    6. Negative prompt block ("no text, no watermarks, no logos, no extra fingers, no AI artifacts, no stock-photo cliches")
    
    Generic prompts FAIL validator. Same template applies to FLUX, Recraft, Stability.
    
    ## Fail-CLOSED Auto-Regenerate (BUILD-BREAKING — `validate-no-empty-slots.mjs`)
    
    Every slot MUST end build w/ `filled_url != null AND filled_score >= relevance_floor` (default 8/10 via GPT Image 2 vision). Failure modes (Pexels empty, NSFW-flagged, broken scrape, vision below floor) → immediate re-gen w/ REFINED prompt — NEVER silent skip, NEVER substitute brand-gradient unless 5 attempts exhausted. `media_pipeline_orchestrator` sub-agent owns this loop. See `media-acquisition.md` § Fail-CLOSED chain.
    
    ## Logo / Icon / Video / OG
    
    - **Logo** — Ideogram v3 (best text rendering); **Icons** — Recraft V3; output: PNG transparent + SVG; bg removal → favicon set (16/32/180/192/512 + maskable); brand mark MUST be vector-clean
    - **Video** — Sora (primary cinematic); Veo (narrative stitching, 7-8 × 8-sec clips → 60-sec arc); HeyGen (explainer/spokesperson); captions VTT + transcript; `prefers-reduced-motion` → static poster fallback
    - **OG (1200×630)** — Satori edge-rendered, per-route unique, BRANDED CARD never raw photo, ≤100KB, cached KV 7d / R2 forever
    
    ## Stock Photography + Asset Compression + Performance
    
    **Stock**: Pexels first (free, API); Pixabay second. Never Unsplash (generic), iStock/Getty (paid). Critique-and-remix loop max 3 rounds; AI vision <7/10 = reject + regenerate.
    
    **Compression**: AVIF primary (94% browser support, 20-30% smaller than WebP); WebP fallback (Safari 14+); JPEG legacy. Sharp: 320/640/1280/1920w srcset; blur placeholder; dominant color → CSS bg fill. R2 upload per-extension content-type.
    
    **Budgets**: total images/page ≤500KB; largest single ≤200KB; Hero LCP `fetchpriority="high"` + preload; `loading="lazy"` + `decoding="async"` on all others.
    
    ## See submodules for: media-prompts, compression-pipeline, og-image-generation, image-optimization, image-profiling, lightbox-classifier, social-brand-hex, notebooklm-pipeline, build-breaking-rules.
    
  • social-brand-hex.md 3.8 KB
    ---
    name: "social-brand-hex"
    description: "Canonical brand hex map for every social platform. Icons hover/focus/active to brand color, not generic accent. JSON map encoded for builder consumption. Footer + header + contact tiles all use this map."
    updated: "2026-05-01"
    ---
    
    # Social Media Brand-Hex Map (***NON-NEGOTIABLE — EVERY SOCIAL ICON***)
    
    Every social icon link must hover to ITS brand color — not the generic accent. Generic accent on hover = AI slop. Brand-hex hover = polish. Same map applied in footer, header, contact tiles, share buttons, social proof rows.
    
    ## Canonical Hex Map (`src/data/social-brand-hex.ts`)
    
    ```ts
    export const SOCIAL_BRAND_HEX: Record<string, string> = {
      facebook:  '#1877F2',
      linkedin:  '#0A66C2',
      twitter:   '#000000',  // X rebrand 2023
      x:         '#000000',
      instagram: 'linear-gradient(135deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)',  // gradient — apply via background-image + -webkit-background-clip on icon stroke
      youtube:   '#FF0000',
      tiktok:    '#000000',  // black icon, cyan/magenta accents on hover via filter shadow
      pinterest: '#BD081C',
      github:    '#181717',
      discord:   '#5865F2',
      threads:   '#000000',
      bluesky:   '#0085FF',
      mastodon:  '#6364FF',
      reddit:    '#FF4500',
      slack:     '#4A154B',
      whatsapp:  '#25D366',
      telegram:  '#26A5E4',
      snapchat:  '#FFFC00',
      twitch:    '#9146FF',
      vimeo:     '#1AB7EA',
      spotify:   '#1DB954',
      applemusic:'#FA243C',
      medium:    '#000000',
      substack:  '#FF6719',
      patreon:   '#F96854',
      email:     'var(--brand-accent)',
      phone:     'var(--brand-accent)',
      rss:       '#FFA500',
    };
    ```
    
    ## Hover/Focus/Active CSS Pattern
    
    Icon stays neutral (`var(--text-secondary)` or `currentColor`) by default; transitions to brand-hex on `:hover` AND `:focus` AND `:focus-visible` AND `:active`. Smooth transition via `--ease-out`.
    
    ```css
    .social-link {
      color: var(--text-secondary);
      transition: color 0.2s var(--ease-out), transform 0.15s var(--ease-out);
    }
    .social-link:hover, .social-link:focus, .social-link:focus-visible, .social-link:active {
      color: var(--social-brand-hex);  /* set via inline style or per-platform class */
      transform: translateY(-2px);
    }
    .social-link:active { transform: translateY(0) scale(0.96); }
    ```
    
    ## TikTok / Instagram Special Cases
    
    - **TikTok** — uses cyan+magenta accents; render the hover state as the primary black plus a `text-shadow: 2px 0 #FF0050, -2px 0 #00F2EA;` glitch effect for full brand fidelity.
    - **Instagram** — is a gradient; apply via SVG `linearGradient` def on the icon path's `fill` AND use `background-image` + `-webkit-background-clip: text` on text labels.
    
    ## Per-Platform Class Generation
    
    Build emits `.social-link--{platform}` per icon:
    
    ```html
    <a class="social-link social-link--linkedin" style="--social-brand-hex: #0A66C2">
    ```
    
    CSS variable indirection lets a single rule handle all platforms. Builder reads `_research.json.social[]` array, maps each to `SOCIAL_BRAND_HEX[platform]`, emits inline `--social-brand-hex` style.
    
    ## Aria-Labels (***ACCESSIBILITY***)
    
    Every social icon link has descriptive aria-label including platform AND business name: `aria-label="Visit {BusinessName} on LinkedIn"` not just `aria-label="LinkedIn"`. Screen reader users get full context.
    
    ## Build Gate
    
    `validate-social-brand-hex.mjs` — for every `<a>` matching `[class*="social-link--"]`, assert:
    
    - `--social-brand-hex` CSS var resolves to a hex matching `SOCIAL_BRAND_HEX[platform]`
    - Hover transition declared
    - `focus-visible` state distinct from default
    - `aria-label` contains both platform name AND business name
    
    Fail build on any miss.
    
    ## Anti-Pattern (caught in lone-mountain-2)
    
    LinkedIn + Twitter + Instagram icons all hovered to `var(--brand-accent)` (cyan) — every platform looked identical. Fix: import `SOCIAL_BRAND_HEX` from `src/data/social-brand-hex.ts`, render each icon with its own `--social-brand-hex` variable.
    
  • technical-diagramming.md 4.4 KB
    ---
    name: Technical Diagramming
    description: ASCII, Mermaid, SVG diagram generation for architecture docs, data flows, and system maps
    version: "2.0.0"
    updated: "2026-04-23"
    ---
    
    # Technical Diagramming
    
    ## Format Selection
    
    - **ASCII** — README/terminal/inline docs, zero dependencies, universal rendering
    - **Mermaid** — GitHub/GitLab auto-render, versioned in markdown, CI-friendly
    - **SVG** — high-fidelity exports, presentations, external docs
    - **D2** — declarative alternative to Mermaid, auto-layout
    
    ### When to Use Which
    
    - **README** — ASCII (renders everywhere, no extensions)
    - **GitHub PR/wiki** — Mermaid (native rendering, diffable)
    - **Architecture doc** — Mermaid + SVG export (version + present)
    - **Terminal output** — ASCII (monospace guaranteed)
    - **Slide deck** — SVG/PNG via `freeze` (high-fidelity)
    
    ## ASCII Diagrams
    
    ### Box-Drawing Characters
    
    ```
    ┌─────────┐    ┌──────────┐    ┌─────────┐
    │  Client  │───▶│  Worker  │───▶│   D1    │
    └─────────┘    └──────────┘    └─────────┘
                        │
                        ▼
                  ┌──────────┐
                  │    R2    │
                  └──────────┘
    ```
    
    Chars:
    
    - `┌ ─ ┐ │ └ ┘ ├ ┤ ┬ ┴ ┼` for boxes
    - `─→ ──▶ ◀── ←─` for arrows
    - `···` for optional
    - `═══` for emphasis
    
    ### Reusable Architecture Template
    
    ```
    ┌──────────────────────────────────────────┐
    │              Cloudflare Edge             │
    │  ┌────────┐  ┌────────┐  ┌────────┐     │
    │  │ Worker │  │  KV    │  │  R2   │     │
    │  └───┬────┘  └────────┘  └────────┘     │
    │      │                                   │
    │  ┌───▼────┐  ┌────────┐                 │
    │  │   D1   │  │  DO    │                 │
    │  └────────┘  └────────┘                 │
    └──────────────────────────────────────────┘
    ```
    
    ## Mermaid.js
    
    ### Diagram Types
    
    - `flowchart` — system arch
    - `sequence` — API flows
    - `classDiagram` — data models
    - `erDiagram` — DB schema
    - `stateDiagram` — state machines
    - `gantt` — timelines
    - `C4Context` — high-level arch
    
    ### Flowchart
    
    ```mermaid
    flowchart LR
        A[Client] -->|HTTPS| B[CF Worker]
        B -->|Query| C[(D1)]
        B -->|Store| D[(R2)]
        B -->|Cache| E[KV]
        B -->|Auth| F[Clerk]
    ```
    
    ### Sequence
    
    ```mermaid
    sequenceDiagram
        participant U as User
        participant W as Worker
        participant C as Clerk
        participant D as D1
        U->>W: POST /api/checkout
        W->>C: verifyToken()
        C-->>W: userId
        W->>D: INSERT order
        D-->>W: orderId
        W-->>U: {url: checkout}
    ```
    
    ### ER Diagram
    
    ```mermaid
    erDiagram
        USER ||--o{ ORDER : places
        ORDER ||--|{ LINE_ITEM : contains
        PRODUCT ||--o{ LINE_ITEM : "ordered in"
    ```
    
    ## Tools
    
    - **`freeze`** (charm.sh) — code/diagram → PNG, terminal-native
    - **`mmdc`** (mermaid-cli) — `.mmd` → SVG/PNG/PDF, CI integration
    - **`d2`** (terrastruct) — declarative diagrams, auto-layout, themes
    - **Excalidraw** — hand-drawn aesthetic, collaborative, embeddable
    
    ### freeze Example
    
    ```bash
    freeze --language mermaid -o arch.png diagram.mmd
    ```
    
    ### mermaid-cli
    
    ```bash
    npx -p @mermaid-js/mermaid-cli mmdc -i diagram.mmd -o diagram.svg -t dark -b '#060610'
    ```
    
    ## Best Practices
    
    - Left-to-right flow (LR) default, top-to-bottom (TB) for hierarchies
    - Max 7±2 nodes per diagram — split complex systems into sub-diagrams
    - Label ALL edges — unlabeled arrows are ambiguous
    - Color for grouping not decoration — subgraphs with fills
    - Consistent spacing — align nodes vertically/horizontally
    - Dark theme: bg `#060610`, node fill `#1a1a2e`, text `#e0e0e0`, edge `#00E5FF`
    
    ## Ownership
    
    - **Owns** — Diagram generation (ASCII + Mermaid + SVG + D2), architecture visualization, data flow diagrams, ER diagrams, deployment maps
    - **Never owns** — Image generation (→ image-gen), brand design (→ 09), UI mockups (→ 10)
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related