diff --git a/Industry Deep Dive Carousel/generate_cover_gif.py b/Industry Deep Dive Carousel/generate_cover_gif.py new file mode 100644 index 0000000..424aa64 --- /dev/null +++ b/Industry Deep Dive Carousel/generate_cover_gif.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +""" +Template — Industry Deep Dive Carousel cover GIF (Top 5 revealing animation). +SCALE = 2 -> 1080x924 px (LinkedIn native portrait quality) + +Shared script for the deep-dive-carousel skill (Step 10). Lives at +Industry Deep Dive Carousel/generate_cover_gif.py — for each new industry, +edit the constants marked "EDIT" below in place, then run it. OUT_PATH +writes into that industry's own subfolder, so nothing here needs moving. +""" + +from PIL import Image, ImageDraw, ImageFont +import os + +# ── Resolution ──────────────────────────────────────────────────────────── +SCALE = 2 # 1 = 540x675 preview | 2 = 1080x1350 LinkedIn quality +W = 540 * SCALE + +def s(v): + """Scale a pixel / font-size value.""" + return int(v * SCALE) + +# Derive canvas height from content — no dead space at bottom +# stats bar: C_Y(258) + C_H(110) + gap(16) + bar_h(62) = 446 +# footer text y: 446 + 14 = 460, font height ~12, padding below 28 +H = s(220 + 110 + 16 + 62 + 14 + 12 + 28) # = s(462) = 924 px at SCALE 2 + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # Industry Deep Dive Carousel/ +# EDIT: point at the industry's own subfolder and output filename +OUT_PATH = os.path.join(BASE_DIR, "[Industry Name]", "[industry-slug]-[year]-cover.gif") +LOGO_PATH = os.path.join(BASE_DIR, "..", "RealRate Logos", "RealRate_logo_horizontal.png") + +# ── Palette ─────────────────────────────────────────────────────────────── EDIT +# Default RealRate blue palette — replace with the industry's chosen palette (Step 4 of the skill) +NAVY = (0, 24, 41) # cover bg dark #001829 +GOLD = (201, 168, 76) # accent #C9A84C +GOLD_L = (219, 191, 110) # accent light +BLUE_L = (61, 186, 205) # blue light #3DBACD +BLUE_D = (0, 103, 155) # blue dark #00679B +GREEN_L = (134, 204, 130) # #86CC82 (ECR score colour — keep green across all palettes) +WHITE = (255, 255, 255) +SILVER = (208, 208, 208) +BRONZE = (212, 149, 106) + +def blend(c, a, bg=NAVY): + """Pre-multiply alpha blend onto bg (a: 0-255).""" + t = a / 255.0 + return tuple(int(ci * t + bi * (1 - t)) for ci, bi in zip(c, bg)) + +# ── Company data ─────────────────────────────────────────────────────────── EDIT +# Replace with the industry's actual Top 5 (name, ECR, Top-Rated status) from the live rankings page +TOP5 = [ + {"rank": 1, "lines": ["Company One"], "ecr": "0%", "top_rated": True, + "rank_c": GOLD_L, "border_c": GOLD, "b_a": 140, "bg_a": 25}, + {"rank": 2, "lines": ["Company Two"], "ecr": "0%", "top_rated": True, + "rank_c": SILVER, "border_c": SILVER, "b_a": 115, "bg_a": 17}, + {"rank": 3, "lines": ["Company Three"], "ecr": "0%", "top_rated": True, + "rank_c": BRONZE, "border_c": BRONZE, "b_a": 115, "bg_a": 20}, + {"rank": 4, "lines": ["Company Four"], "ecr": "0%", "top_rated": True, + "rank_c": BLUE_L, "border_c": BLUE_L, "b_a": 76, "bg_a": 15}, + {"rank": 5, "lines": ["Company Five"], "ecr": "0%", "top_rated": True, + "rank_c": BLUE_L, "border_c": BLUE_L, "b_a": 76, "bg_a": 15}, +] + +# Reveal order: rank 5 first -> rank 1 last (indices into TOP5) +REVEAL_ORDER = [4, 3, 2, 1, 0] + +# ── Card geometry ───────────────────────────────────────────────────────── +C_PAD = s(20) +C_GAP = s(6) +C_W = (W - 2 * C_PAD - 4 * C_GAP) // 5 +C_Y = s(220) +C_H = s(110) + +def card_x(i): + return C_PAD + i * (C_W + C_GAP) + +# ── Font loader ─────────────────────────────────────────────────────────── +def load_font(bold=False, black=False, size=12): + if black: + cands = [r"C:\Windows\Fonts\ariblk.ttf", + r"C:\Windows\Fonts\segoeuib.ttf", + r"C:\Windows\Fonts\arialbd.ttf"] + elif bold: + cands = [r"C:\Windows\Fonts\segoeuib.ttf", + r"C:\Windows\Fonts\arialbd.ttf"] + else: + cands = [r"C:\Windows\Fonts\segoeui.ttf", + r"C:\Windows\Fonts\arial.ttf"] + for p in cands: + try: + return ImageFont.truetype(p, size) + except OSError: + pass + return ImageFont.load_default() + +fRank = load_font(True, False, s(7)) +fName = load_font(True, False, s(7)) +fEcr = load_font(True, False, s(13)) +fBadge = load_font(True, False, s(6)) +fLabel = load_font(True, False, s(8)) +fTitle = load_font(False, True, s(30)) +fYear = load_font(True, False, s(13)) +fStatN = load_font(True, False, s(26)) +fStatL = load_font(True, False, s(8)) +fFoot = load_font(False, False, s(8)) + +# ── Company logo loader ───────────────────────────────────────────────────── EDIT +# Replace with the [company-slug]-logo.png filenames sourced in Step 5, ranks 1–5 +_LOGO_FILES = { + 0: "company-one-logo.png", + 1: "company-two-logo.png", + 2: "company-three-logo.png", + 3: "company-four-logo.png", + 4: "company-five-logo.png", +} + +def _load_logo(filename): + """Load a PNG logo and convert all opaque pixels to white (CSS brightness(0) invert(1) equivalent).""" + path = os.path.join(BASE_DIR, "[Industry Name]", filename) # EDIT: match OUT_PATH's subfolder + try: + img = Image.open(path).convert("RGBA") + a = img.split()[3] # keep original alpha mask + white = Image.new("RGBA", img.size, (255, 255, 255, 255)) + white.putalpha(a) + return white + except Exception: + return None + +def _fit_logo(logo, max_w, max_h): + """Resize a logo RGBA image to fit inside max_w × max_h, preserving aspect ratio.""" + ratio = min(max_w / logo.width, max_h / logo.height) + nw = max(1, int(logo.width * ratio)) + nh = max(1, int(logo.height * ratio)) + return logo.resize((nw, nh), Image.LANCZOS) + +# Pre-load and pre-fit all logos once at startup — avoids per-frame LANCZOS resizes in stamp_card() +_LB_W = C_W - s(16) +_LB_H = s(22) +LOGOS = {i: _load_logo(f) for i, f in _LOGO_FILES.items()} +FITTED_LOGOS = {i: _fit_logo(logo, _LB_W, _LB_H) if logo is not None else None + for i, logo in LOGOS.items()} + +# ── Text helper ─────────────────────────────────────────────────────────── +def tc(draw, text, cx, y, fnt, fill): + """Draw text horizontally centred at cx, top at y.""" + bb = draw.textbbox((0, 0), text, font=fnt) + draw.text((cx - (bb[2] - bb[0]) // 2, y), text, font=fnt, fill=fill) + +# ── Build static base (background + UI, no company cards) ───────────────── +def make_base(): + img = Image.new("RGB", (W, H)) + d = ImageDraw.Draw(img) + + # Cover gradient top -> bottom — EDIT: match the industry's --cover-bg-start / --cover-bg-end (Step 4) + GRAD_START = (0, 24, 41) # #001829 + GRAD_END = (0, 45, 78) # #002d4e + for y in range(H): + t = y / H + r = int(GRAD_START[0] * (1 - t) + GRAD_END[0] * t) + g = int(GRAD_START[1] * (1 - t) + GRAD_END[1] * t) + b = int(GRAD_START[2] * (1 - t) + GRAD_END[2] * t) + d.line([(0, y), (W, y)], fill=(r, g, b)) + + # Very faint inner border + bm = s(12) + d.rectangle([bm, bm, W - bm - 1, H - bm - 1], outline=blend(WHITE, 18)) + + # Gold L-corner marks + M, ARM = s(12), s(24) + for (ax, dx) in [(M, 1), (W - M, -1)]: + for (ay, dy) in [(M, 1), (H - M, -1)]: + d.line([(ax, ay), (ax + dx * ARM, ay)], fill=GOLD, width=s(2)) + d.line([(ax, ay), (ax, ay + dy * ARM)], fill=GOLD, width=s(2)) + + # ── RealRate logo pill ─────────────────────────────────────────────── + pw, ph = s(160), s(40) + px = (W - pw) // 2 + py = s(22) + + logo_ok = False + try: + logo = Image.open(LOGO_PATH).convert("RGBA") + lh = s(24) + lw = int(logo.width * lh / logo.height) + logo = logo.resize((lw, lh), Image.LANCZOS) + lx = px + (pw - lw) // 2 + ly = py + (ph - lh) // 2 + img_rgba = img.convert("RGBA") + d2 = ImageDraw.Draw(img_rgba) + d2.rounded_rectangle([px, py, px + pw, py + ph], radius=s(6), fill=(255,255,255,255)) + overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + overlay.paste(logo, (lx, ly), logo) + img = Image.alpha_composite(img_rgba, overlay).convert("RGB") + logo_ok = True + except Exception: + pass + + d = ImageDraw.Draw(img) + if not logo_ok: + fL = load_font(True, False, s(15)) + tc(d, "RealRate", W // 2, py + s(11), fL, BLUE_D) + + # ── Title block ────────────────────────────────────────────────────── EDIT + tc(d, "FINANCIAL HEALTH REPORT", W // 2, s(76), fLabel, GOLD) + tc(d, "[INDUSTRY NAME]", W // 2, s(94), fTitle, WHITE) + + # Year bar: decorative lines + year number + ybar = s(150) + d.line([(s(40), ybar), (s(208), ybar)], fill=blend(BLUE_L, 128)) + d.line([(s(332), ybar), (s(500), ybar)], fill=blend(BLUE_L, 128)) + tc(d, "[YEAR]", W // 2, s(142), fYear, BLUE_L) + + # Gold accent rule + d.line([(W // 2 - s(20), s(155)), (W // 2 + s(20), s(155))], + fill=GOLD, width=s(2)) + + # Horizontal separator + d.line([(s(25), s(168)), (W - s(25), s(168))], fill=blend(WHITE, 32)) + + # Section label above cards + tc(d, "TOP 5 . FINANCIAL HEALTH RANKINGS", + W // 2, s(180), fLabel, blend(WHITE, 110)) + + # ── Stats bar ──────────────────────────────────────────────────────── + SY = C_Y + C_H + s(16) + SEY = SY + s(62) + d.line([(0, SY - 1), (W, SY - 1)], fill=blend(WHITE, 22)) + d.line([(0, SEY), (W, SEY)], fill=blend(WHITE, 22)) + + # EDIT: companies ranked · industry avg ECR · Top-Rated count (from Step 2 rankings pull) + for i, (num, lbl) in enumerate([("0", "COMPANIES"), ("0%", "AVG ECR"), ("0", "TOP-RATED")]): + cx_s = W // 6 + i * (W // 3) + if i > 0: + d.line([(cx_s - W // 6, SY), (cx_s - W // 6, SEY)], fill=blend(WHITE, 24)) + tc(d, num, cx_s, SY + s(8), fStatN, WHITE) + tc(d, lbl, cx_s, SY + s(42), fStatL, blend(WHITE, 97)) + + # Footer + tc(d, "Powered by RealRate . Using Explainable Financial AI", + W // 2, SEY + s(14), fFoot, blend(WHITE, 75)) + + return img + +# ── Stamp one company card onto an existing image ───────────────────────── +def stamp_card(base, idx, y_off=0): + co = TOP5[idx] + out = base.copy() + d = ImageDraw.Draw(out) + x = card_x(idx) + y = C_Y + y_off + cx = x + C_W // 2 + + bg_c = blend(co["border_c"], co["bg_a"]) + bdr_c = blend(co["border_c"], co["b_a"]) + + # Gold outer glow ring for rank 1 + if co["rank"] == 1: + d.rounded_rectangle([x - s(3), y - s(3), x + C_W + s(3), y + C_H + s(3)], + radius=s(10), outline=blend(GOLD, 80), width=s(3)) + + d.rounded_rectangle([x, y, x + C_W, y + C_H], + radius=s(7), fill=bg_c, outline=bdr_c, width=s(2)) + + # Rank label + tc(d, f"#{co['rank']}", cx, y + s(7), fRank, co["rank_c"]) + + # Company logo + lb_y = y + s(22) + fitted = FITTED_LOGOS.get(idx) + if fitted is not None: + lx = x + s(8) + (_LB_W - fitted.width) // 2 + ly = lb_y + (_LB_H - fitted.height) // 2 + out.paste(fitted, (lx, ly), fitted) + else: + d.rectangle([x + s(8), lb_y, x + C_W - s(8), lb_y + _LB_H], + fill=blend(WHITE, 15)) + + # Company name (two lines) + ny = y + s(54) + for line in co["lines"]: + tc(d, line, cx, ny, fName, blend(WHITE, 224)) + ny += s(10) + + # ECR score + tc(d, co["ecr"], cx, y + s(79), fEcr, GREEN_L) + + # "TOP-RATED" badge — only for top-rated companies + if co.get("top_rated", True): + bb = d.textbbox((0, 0), "TOP-RATED", font=fBadge) + bw = bb[2] - bb[0] + bx = cx - bw // 2 - s(4) + by = y + C_H - s(14) + d.rounded_rectangle([bx, by - s(2), bx + bw + s(8), by + s(9)], + radius=s(2), fill=blend(GREEN_L, 51)) + tc(d, "TOP-RATED", cx, by, fBadge, GREEN_L) + + return out + +# ── Assemble animation frames ───────────────────────────────────────────── +def build_frames(): + base = make_base() + frames = [] + durs = [] + visible = [] + + def render(new_idx=None, y_off=0): + img = base.copy() + for vi in visible: + img = stamp_card(img, vi) + if new_idx is not None: + img = stamp_card(img, new_idx, y_off) + return img + + # Intro: title + stats only + for _ in range(4): + frames.append(base.copy()) + durs.append(80) + + # Reveal each card: slide up then hold + for card_idx in REVEAL_ORDER: + for y_off in [s(45), s(25), s(8)]: + frames.append(render(card_idx, y_off)) + durs.append(60) + frames.append(render(card_idx, 0)) + durs.append(950 if card_idx == 0 else 480) + visible.append(card_idx) + + # Final hold: all 5 visible + frames.append(render()) + durs.append(2600) + + return frames, durs + +# ── Main ────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + print(f"Canvas: {W}x{H} px (SCALE={SCALE})") + print("Building frames...") + frames, durs = build_frames() + print(f" {len(frames)} frames") + + print("Quantizing to 256 colours...") + pf = [f.quantize(colors=256, method=Image.Quantize.MEDIANCUT, dither=0) + for f in frames] + + print(f"Saving to: {OUT_PATH}") + pf[0].save( + OUT_PATH, + save_all=True, + append_images=pf[1:], + duration=durs, + loop=0, + ) + + kb = os.path.getsize(OUT_PATH) // 1024 + print(f"Done - {kb} KB ({len(frames)} frames)") diff --git a/RealRate Logos/RealRate_logo_horizontal.png b/RealRate Logos/RealRate_logo_horizontal.png new file mode 100644 index 0000000..b143c55 Binary files /dev/null and b/RealRate Logos/RealRate_logo_horizontal.png differ diff --git a/RealRate Logos/RealRate_logo_vertical.png b/RealRate Logos/RealRate_logo_vertical.png new file mode 100644 index 0000000..3750b57 Binary files /dev/null and b/RealRate Logos/RealRate_logo_vertical.png differ diff --git a/skills/deep-dive-carousel.md b/skills/deep-dive-carousel.md index 1eac2a3..a834096 100644 --- a/skills/deep-dive-carousel.md +++ b/skills/deep-dive-carousel.md @@ -2,7 +2,11 @@ Generate a complete Industry Deep Dive Carousel for a given industry and year. Produces all required output files: interactive HTML, print HTML, PDF, animated GIF, and LinkedIn post. -All paths are relative to the project root (the `RealRate/` folder). Run commands from that directory. +This skill is self-contained — everything needed to run it lives in this repo. All paths below are relative to the repo root. Run commands from that directory. + +**Bundled assets this skill depends on (already in this repo — do not skip or recreate):** +- `RealRate Logos/RealRate_logo_horizontal.png` — used in every HTML export and the cover GIF +- `Industry Deep Dive Carousel/generate_cover_gif.py` — master GIF template (Step 10) ## Usage @@ -16,13 +20,13 @@ Example: `/deep-dive-carousel "US Software" 2026` ## Step 0 — Read context files -Before doing anything else, read all of these from the project root: +Before doing anything else, read: -- `Industry Deep Dive Carousel/CLAUDE.md` -- `Industry Deep Dive Carousel/deep-dive-template.md` -- `context/brand-context.md` +- `context/brand-core.md` - `context/brand-voice.md` +All industry-carousel design rules (colours, logo handling, cover-slide sizing, ECR math, output checklist) are inlined in the steps below — there is no separate CLAUDE.md to fetch for this skill. + --- ## Step 1 — Verify the industry URL slug @@ -66,7 +70,7 @@ Extract for each company in the Top 10: ## Step 4 — Choose the industry colour scheme -Pick a colour palette distinct from all previously used schemes. Update `:root` CSS variables and the cover/Slide 7/Slide 10 gradients accordingly. The colour table in `Industry Deep Dive Carousel/CLAUDE.md` lists existing schemes — do not reuse them. +Pick a colour palette distinct from all previously used schemes. Update `:root` CSS variables and the cover/Slide 7/Slide 10 gradients accordingly. Check the `:root` variables in each existing `Industry Deep Dive Carousel/*/[industry]-*.html` file in this repo before choosing — never reuse a palette already in use. Example palette variables to set: ```css @@ -118,11 +122,11 @@ File: `Industry Deep Dive Carousel/[Industry Name]/[industry-slug]-[year].html` **Spec:** 540×675px interactive preview · 10 slides · slide navigation dots. -Follow all design rules from `Industry Deep Dive Carousel/CLAUDE.md`: +Slide-by-slide design rules: - **Cover (Slide 1):** Two rows of 5 cards (Top 10). Rank colours: gold/silver/bronze/#4–5 blue-light/#6–10 blue-light. Stats bar: companies · avg ECR · Top-Rated count. RealRate logo in white pill top-center. - **Slide 2 — Industry at a Glance:** Key stats, YoY ECR change, avg ECR bar fill proportional to ECR on a 600% scale. -- **Slide 3 — All Top-Rated:** Rows #1–#5 with driver chip; rows #6+ without. Compress rows if 9+ companies (`gap: 5px`, `padding: 8px 12px`). +- **Slide 3 — All Top-Rated:** Rows #1–#5 with driver chip; rows #6+ without. Compress rows if 9+ companies (`gap: 5px`, `padding: 8px 12px` interactive; `gap: 10px`, `padding: 16px 28px` print). - **Slide 4 — Biggest Mover:** Rank before/after, ECR, key driver with point contribution. - **Slide 5 — The Surprise:** Company, rank, ECR, market assumption vs. data reality, structural explanation. - **Slide 6 — Warning Signal:** Aggregate only — no company names. Red accent treatment. @@ -133,7 +137,32 @@ Follow all design rules from `Industry Deep Dive Carousel/CLAUDE.md`: Font: Manrope throughout. No emojis on any slide. Footer on every slide except Slide 9 (use `realrate.ai/methodology` instead). -RealRate logo path in interactive HTML: `../../RealRate Logos/RealRate_logo_horizontal.png` +RealRate logo path in interactive HTML: `../../RealRate Logos/RealRate_logo_horizontal.png` (resolves to the bundled `RealRate Logos/` folder at the repo root). + +### Cover slide font & size reference + +Both rows (Top 10) use identical card structure: rank · logo · name · ECR% · Top-Rated badge. `min-height` on the name element keeps ECR% and the badge pinned at the same row position whether the company name wraps to 1 or 2 lines: + +```css +/* Interactive */ +.co5-name, .co-rest-name { min-height: 17px; display: flex; align-items: center; justify-content: center; } +/* Print */ +.co5-name, .co-rest-name { min-height: 48px; display: flex; align-items: center; justify-content: center; } +``` + +| Element | Interactive (540px) | Print (1080px design) | +|---|---|---| +| Report label | 10px | 24px | +| Industry name | 40px | 88px | +| Year | 16px | 36px | +| Rank label | 10px | 20px | +| Logo area height | 32px | 68px | +| Logo max-height | 28px | 58px | +| Logo max-width | 88px | 176px | +| Company name | 7px · min-height 17px | 20px · min-height 48px | +| ECR score | 12px | 32px | +| Top-Rated badge | 6px | 12px | +| Stats number | 32px | 68px | --- @@ -159,7 +188,7 @@ body { width: 1080px; margin: 0; zoom: 1.11111; } Resolve the project root from the current working directory and embed logos immediately after writing the print HTML: ```powershell -$projectRoot = (Get-Location).Path # run from RealRate/ project root +$projectRoot = (Get-Location).Path # run from the repo root $rrLogoPath = Join-Path $projectRoot "RealRate Logos\RealRate_logo_horizontal.png" $rr = "data:image/png;base64," + [Convert]::ToBase64String([IO.File]::ReadAllBytes($rrLogoPath)) @@ -207,7 +236,7 @@ if (Test-Path "C:\Temp\rr_out.pdf") { ## Step 10 — Generate the animated cover GIF -Update `Industry Deep Dive Carousel/generate_cover_gif.py` with industry-specific constants: +This skill bundles `Industry Deep Dive Carousel/generate_cover_gif.py` as a reusable template — every constant marked `# EDIT` inside it defaults to a placeholder. Edit it in place for this industry, run it, then leave it edited (the next industry run edits it again): | Constant | Value | |---|---|