#!/usr/bin/env python3 """Turn each finding into a ready-to-film VIDEO KIT. The founder films manually (screen-recording the live map), so this emits everything needed to shoot a payoff-first "cheaper twin" video without writing anything: the hook, a beat-by-beat shot list tied to applying each filter, a human-VO narration script, ≤6-word captions, the exact map URL to record, and the YouTube title/description/chapters/tags/thumbnail. It also prints a storyboard spec (filters + suggested city) for the optional automated render path (video/src/storyboard.ts AD_CONFIGS + render.sh, which needs the running stack + login creds, so that part is yours to run). Outputs: analysis/out/video_scripts/.md: one filming kit per finding analysis/out/video_scripts/INDEX.md: overview + how to film Run: source .venv/bin/activate && python analysis/build_video_scripts.py (after generate_findings.py) """ from __future__ import annotations import json import re from pathlib import Path FIND = Path("analysis/out/findings") OUT = Path("analysis/out/video_scripts") SITE = "https://perfect-postcode.co.uk" SOURCES = "Land Registry, EPC, Ofsted, DfT, Police.uk" # Suggested CityKey for the optional auto-render path (storyboard.ts CityKey union). CITY_OUTCODES = { "manchester": {"M", "SK", "OL", "BL", "WN"}, "birmingham": {"B"}, "bristol": {"BS"}, "leeds": {"LS", "WF", "BD"}, } LONDON = {"E", "EC", "WC", "W", "SW", "SE", "N", "NW", "BR", "IG", "RM", "TW", "KT", "HA", "SM", "CR", "UB", "EN", "DA"} def gbp(n) -> str: return f"£{int(n):,}" def outward_area(sector: str) -> str: return re.match(r"^[A-Z]+", sector).group(0) def city_key(pricey_sector: str) -> str: area = outward_area(pricey_sector) if area in LONDON: return "london" for city, codes in CITY_OUTCODES.items(): if area in codes: return city return "london" # fallback; only matters for the auto-render variant def twin_kit(f: dict) -> str: p, w, s = f["pricey"], f["twin"], f["stats"] pn, wn = p["name"] or p["sector"], w["name"] or w["sector"] typ = s["dominant_type"].lower() plural = { "Flats/Maisonettes": "flats", "Terraced": "terraced houses", "Semi-Detached": "semi-detached houses", "Detached": "detached houses", }.get(s["dominant_type"], typ + "s") gap = f"{s['gap_pct']:.0f}%" money = gbp(s["gap_on_90sqm"]) url = f["map_url"] titles = [ f["title"], f"{pn} vs {wn}: same station, same schools, {money} cheaper", f"Why {wn} is the smart-money version of {pn} ({gap} less per m²)", ] captions = [ f"{pn} vs {wn}", f"Same station. Same schools.", f"{money} cheaper", f"Same {typ}, ~{s['build_year']}", f"{gap} less per m²", "Find your cheaper twin, free", ] narration = ( f"This is {pn}. And this is {wn}, right next door. Same station. " f"Same {'secondary school catchment' if s['good_secondary_catchments'] else 'schools'}. " f"The same kind of home: {plural} built around {s['build_year']}. " f"On every measure that moves price, they're twins. " f"But watch the price per square metre. {pn}: {gbp(p['est_psqm'])}. {wn}: {gbp(w['est_psqm'])}. " f"That's {gap} cheaper, about {money} on a typical 90-square-metre home, " f"for the same life, one postcode over. You're not paying for the house. You're paying for the name. " f"You can find the cheaper twin of any postcode in England on the map for free, no signup." ) chapters = [ ("0:00", f"The two postcodes ({pn} & {wn})"), ("0:08", "Same station"), ("0:18", "Same school catchment"), ("0:28", "Same kind of home"), ("0:38", "The price-per-m² reveal"), ("0:52", "Find your own cheaper twin (free map)"), ] description = ( f"{url}\n\n" f"{pn} and {wn} share a station, a school catchment and the same era of housing, but {wn} costs about " f"{gap} less per square metre ({money} on a 90 m² home). I built a map that ranks every postcode in " f"England by what each pound actually buys, from official open data ({SOURCES}). Find the cheaper twin " f"of any area, free and with no signup, at {SITE}.\n\n" + "\n".join(f"{ts} {label}" for ts, label in chapters) + "\n\nData: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. " "Figures are estimates aggregated to postcode sector, not valuations." ) shotlist = [ ("0:00–0:06", "COLD OPEN: payoff first", f"Open on the map already showing both areas with the £/m² gap visible. Caption: '{money} cheaper'. Say the hook.", "Land on the map URL below (filters pre-applied)."), ("0:06–0:18", "Same station", "Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want.", "Caption: 'Same station.'"), ("0:18–0:28", "Same schools", "Show the Good+ secondary catchment covering both.", "Caption: 'Same school catchment.'"), ("0:28–0:38", "Same homes", f"Note the dominant type ({plural}) and build era (~{s['build_year']}).", "Caption: 'Same homes.'"), ("0:38–0:52", "THE REVEAL", f"Show the £/m² side by side: {pn} {gbp(p['est_psqm'])} vs {wn} {gbp(w['est_psqm'])}.", f"Caption: '{gap} less per m²'."), ("0:52–1:00", "CTA", "End on the map; invite them to find their own cheaper twin.", "Caption: 'Free. No signup.'"), ] ck = city_key(p["sector"]) psqm_cap = int(round(w["est_psqm"] * 1.05, -2)) spec = { "name": f"twin-{f['slug'].split('/')[-1]}", "city": ck, "promptText": f"Best value {typ}s near {pn}: same schools and station, lower price", "initialFilters": { "Est. price per sqm": [0, psqm_cap], "Good+ secondary school catchments": [1, 11], }, "outroLine": f"{wn}: same life, {gap} cheaper.", } lines = [ f"# Video kit: {pn} vs {wn}", "", f"**Page:** {SITE}{f['page_path']} · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut", "", f"## 🎬 Map URL to record (open this, hit record)", f"`{url}`", "*(filters are pre-applied so the value is on screen immediately)*", "", "## Hook (first 2 seconds, on screen + said)", f"**\"{money} cheaper. Same station. Same schools.\"**", "", "## Shot list", "| Time | Beat | What to show | On-screen |", "|------|------|--------------|-----------|", ] for t, beat, show, cap in shotlist: lines.append(f"| {t} | {beat} | {show} | {cap} |") lines += [ "", "## Narration (human voiceover, never raw TTS for a property audience)", f"> {narration}", "", "## Captions (≤6 words, sound-off)", *[f"- {c}" for c in captions], "", "## YouTube", "**Title options:**", *[f"{i+1}. {t}" for i, t in enumerate(titles)], "", "**Thumbnail text:** big number `" + money + " cheaper` + the two names `" + f"{pn} → {wn}`", "", "**Description (paste as-is):**", "```", description, "```", "", "## 9:16 Short (cut from the same recording)", f"First 3 seconds: the £/m² reveal ({pn} {gbp(p['est_psqm'])} → {wn} {gbp(w['est_psqm'])}) + caption '{gap} less'. " "End card: 'Find your cheaper twin, free, no signup.'", "", "## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS)", "Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). " "Filter names must match live `/api/features` or preflight fails.", "```json", json.dumps(spec, indent=2), "```", ] return "\n".join(lines) def main(): OUT.mkdir(parents=True, exist_ok=True) findings = [json.loads(p.read_text()) for p in sorted(FIND.glob("*.json"))] twins = [f for f in findings if f["type"] == "cheaper_twin"] made = [] for f in twins: slug = f["slug"].split("/")[-1] (OUT / f"{slug}.md").write_text(twin_kit(f)) made.append((slug, f)) index = [ "# Video kits: film one per 1–2 weeks", "", "Each kit is a complete, payoff-first faceless video you can screen-record off the live map. " "Pick one, open its Map URL, record, read the narration (human voice), export one clean cut + a 9:16 Short.", "", "**Priority order (relatable family-home twins first, since they convert better than prime London):**", "", "| Kit | Hook | File |", "|-----|------|------|", ] # Family homes first, then the rest, by £ gap. fam = [m for m in made if m[1]["stats"]["dominant_type"] in ("Terraced", "Semi-Detached", "Detached")] rest = [m for m in made if m not in fam] for slug, f in sorted(fam, key=lambda m: m[1]["stats"]["gap_on_90sqm"], reverse=True) + sorted( rest, key=lambda m: m[1]["stats"]["gap_on_90sqm"], reverse=True ): p, w = f["pricey"], f["twin"] index.append(f"| {p['name'] or p['sector']} → {w['name'] or w['sector']} | {f['shocking_number']} / {gbp(f['stats']['gap_on_90sqm'])} | `{slug}.md` |") (OUT / "INDEX.md").write_text("\n".join(index)) print(f"Wrote {len(made)} video kits + INDEX.md to {OUT}/") for slug, f in made[:6]: print(f" {slug}.md: {f['title']}") if __name__ == "__main__": main()