"""TiDB brand constants + platform dimensions. Source of truth for colors, fonts, logo, and per-platform canvas sizes. All paths are resolved relative to this file so the pipeline is portable. """ from pathlib import Path ROOT = Path(__file__).resolve().parent # ── Brand colors (from TiDB brand guidelines, matched to proven WOM composites) ── RED = (220, 21, 11) TEAL = (80, 189, 185) TEAL_DARK = (15, 83, 83) ORANGE = (230, 120, 20) WHITE = (255, 255, 255) COLORS = { "RED": RED, "TEAL": TEAL, "TEAL_DARK": TEAL_DARK, "ORANGE": ORANGE, "WHITE": WHITE, } # ── Font paths (DM Sans — TiDB brand font, referenced in brand guidelines) ── FONT_DIR = ROOT / "fonts" FONT_BOLD = FONT_DIR / "DMSans-Bold.ttf" FONT_MEDIUM = FONT_DIR / "DMSans-Medium.ttf" FONT_REG = FONT_DIR / "DMSans-Regular.ttf" # ── Logo path (from TiDB brand guidelines logo zip, white-on-dark variant) ── LOGO_PATH = ROOT / "assets" / "tidb-logo.png" # ── Per-platform canvas dimensions (matches platform-recomposite.py proven flow) ── # None = no image for this platform (hn/reddit/github are text-only by convention). PLATFORMS = { "linkedin": (1200, 627), "twitter": (1280, 720), # landscape 16:9 (default) "twitter-square": (1080, 1080), # square 1:1 (ads, carousel, engagement) "youtube": (1280, 720), "discord": (1280, 720), # matches YouTube; renders well in embed previews "blog": (1200, 630), # OG standard 1.91:1 "generic": (1200, 630), "facebook": (1200, 630), # OG standard; also works as 1080x1080 for feed "email": (600, 200), "hn": None, "reddit": None, "github": None, "google-ads": None, # externally managed in Google Ads } # ── Per-platform copywriting constraints ── # Used by ideation-skill and post-processor validation. # text_limit: hard char cap for post body / caption # optimal_text: recommended length for engagement # title_limit: hard cap for title/headline (where applicable) # emoji: "none" | "minimal" (1-2) | "moderate" (2-4) | "heavy" (3-8) # hashtags: recommended count; 0 = hashtags not used or harmful PLATFORM_COPY_RULES = { "linkedin": { "text_limit": 3000, "optimal_text": (1200, 1500), "title_limit": None, "emoji": "moderate", # 2-4 as bullet markers or section headers "emoji_count": (2, 4), "hashtags": (3, 5), "tone": "professional but human; personal stories beat corporate messaging", }, "twitter": { "text_limit": 280, "optimal_text": (71, 100), "title_limit": None, "emoji": "minimal", # 1-2 at end or as separators "emoji_count": (1, 2), "hashtags": (1, 2), # final tweet only in threads "tone": "concise, punchy, data-anchored; threads 4-8 tweets", "image_formats": {"landscape": (1280, 720), "square": (1080, 1080), "portrait": (1080, 1350)}, }, "twitter-square": { "text_limit": 280, "optimal_text": (71, 100), "title_limit": None, "emoji": "minimal", "emoji_count": (1, 2), "hashtags": (1, 2), "tone": "concise, punchy; square format for ads/carousel", }, "reddit": { "text_limit": 40000, "optimal_text": (200, 500), # per post body; not counting title "title_limit": 300, "optimal_title": (60, 120), "emoji": "none", # zero in tech subreddits "emoji_count": (0, 0), "hashtags": (0, 0), # not functional on reddit "tone": "developer-first, zero marketing language, community value", }, "hn": { "text_limit": 2000, # practical limit for Show HN body "optimal_text": (300, 800), "title_limit": 80, "optimal_title": (50, 70), "emoji": "none", # strong negative signal "emoji_count": (0, 0), "hashtags": (0, 0), "tone": "factual, neutral, zero sales language; Show HN: [Name] — [what it does]", }, "youtube": { "text_limit": 5000, # description "optimal_text": (150, 300), # first 150 chars above fold "title_limit": 100, "optimal_title": (60, 70), "emoji": "minimal", # 1-2 in title OK "emoji_count": (1, 2), "hashtags": (3, 5), # first 3 appear above title "tone": "clear, benefit-led title; front-load key info in description", }, "discord": { "text_limit": 2000, "optimal_text": (200, 500), "title_limit": 256, # embed title "emoji": "heavy", # 3-8, culturally expected "emoji_count": (3, 8), "hashtags": (0, 0), # not functional "tone": "casual, community-native; emoji and formatting expected", }, "blog": { "text_limit": None, "optimal_text": None, "title_limit": 60, # meta title for SEO "meta_desc": (150, 160), "emoji": "none", # not in meta/titles "emoji_count": (0, 0), "hashtags": (0, 0), "tone": "SEO-friendly title; outcome-focused meta description", }, "email": { "text_limit": None, "subject_limit": 50, # optimal 41-50 chars "optimal_subject": (41, 50), "preview_text": (40, 130), "emoji": "minimal", # 1 in subject OK "emoji_count": (0, 1), "hashtags": (0, 0), "tone": "clear subject, personalized when possible", }, "facebook": { "text_limit": 63206, "optimal_text": (40, 80), "title_limit": None, "emoji": "minimal", # 1-3 "emoji_count": (1, 3), "hashtags": (1, 3), "tone": "short posts win; link in comments for reach", }, } # Reference canvas height that the proven WOM composite was tuned for. # Fonts, margins, logo all scale relative to this. REFERENCE_H = 628 # ── Viewer output directory (where p-*.png files land so post-processor finds them) ── # CLOUD: PLG_IMAGE_DIR + PLG_IMAGE_BASE_URL env vars route output to the Fly # Volume (/data/images/) and stamp full https URLs (pingcap-gtm-orch.fly.dev) # into the spec so the browser AND HubSpot can fetch the PNGs. # LOCAL: env vars unset → fall back to the legacy hubspot-SIM/src/viewer path # with localhost:3100 URLs (SIM serves /viewer/p-*.png statically). import os as _os VIEWER_DIR = (Path(_os.environ["PLG_IMAGE_DIR"]).resolve() if _os.environ.get("PLG_IMAGE_DIR") else (ROOT / ".." / ".." / "hubspot-SIM" / "src" / "viewer").resolve()) BASE_URL = _os.environ.get("PLG_IMAGE_BASE_URL") or "http://localhost:3100/viewer" def detect_platform(asset: dict) -> str: """Classify an asset into a platform bucket by inspecting name/contentFile/platform hints. Mirrors platform-recomposite.py:detect_platform() verbatim. """ hay = ( (asset.get("name", "") or "") + " " + (asset.get("contentFile", "") or "") + " " + (asset.get("platform", "") or "") ).lower() if "linkedin" in hay: return "linkedin" if "twitter" in hay or " x " in hay: # Check for explicit square/carousel indicators if "square" in hay or "carousel" in hay or "1:1" in hay: return "twitter-square" return "twitter" if "hn_post" in hay or "hn post" in hay or "hackernews" in hay or "hacker news" in hay: return "hn" if "reddit" in hay: return "reddit" if "youtube" in hay or "runofshow" in hay or "clips" in hay: return "youtube" if "discord" in hay: return "discord" if "blog" in hay: return "blog" if "landing" in hay or "calculator" in hay or "case" in hay: return "blog" if "github" in hay or "readmes" in hay: return "github" return "generic"