wip
This commit is contained in:
parent
f1601257c7
commit
c2070693fb
68 changed files with 2305 additions and 212 deletions
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
# Chrome isn't needed for these checks (lint/typecheck/vitest-jsdom), so
|
||||
# skip puppeteer's postinstall browser download — it's slow and a flaky
|
||||
# skip puppeteer's postinstall browser download: it's slow and a flaky
|
||||
# point of failure. The prerender build installs Chrome explicitly.
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
# The product-demo videos and their poster JPGs live in Git LFS (see
|
||||
# .gitattributes). The checkout below needs `lfs: true` to smudge the real
|
||||
# binaries, but the runner image ships without the git-lfs executable, so
|
||||
# install it first — otherwise checkout fails with "Unable to locate
|
||||
# install it first. Otherwise checkout fails with "Unable to locate
|
||||
# executable file: git-lfs".
|
||||
- name: Install Git LFS
|
||||
run: |
|
||||
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
# Without lfs, checkout writes ~130-byte LFS pointer text files, the
|
||||
# Docker build copies those stubs into frontend/dist/video/*, and the
|
||||
# server serves text as video/mp4 — so the videos never load in
|
||||
# server serves text as video/mp4, so the videos never load in
|
||||
# production. Smudge the real binaries instead.
|
||||
with:
|
||||
lfs: true
|
||||
|
|
|
|||
42
analysis/out/findings/cheaper-twin__br3-3-vs-cr0-7.json
Normal file
42
analysis/out/findings/cheaper-twin__br3-3-vs-cr0-7.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/br3-3-vs-cr0-7",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/br3-3-vs-cr0-7",
|
||||
"title": "Beckenham vs Croydon: the same terraced house, about 31% cheaper per m\u00b2",
|
||||
"hook": "\u00a3201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart",
|
||||
"shocking_number": "31%",
|
||||
"pricey": {
|
||||
"sector": "BR3 3",
|
||||
"name": "Beckenham",
|
||||
"label": "Beckenham (BR3 3)",
|
||||
"named": true,
|
||||
"est_psqm": 7153,
|
||||
"n": 4514
|
||||
},
|
||||
"twin": {
|
||||
"sector": "CR0 7",
|
||||
"name": "Croydon",
|
||||
"label": "Croydon (CR0 7)",
|
||||
"named": true,
|
||||
"est_psqm": 4910,
|
||||
"n": 5143
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 31.4,
|
||||
"gap_per_sqm": 2243,
|
||||
"gap_on_90sqm": 201870,
|
||||
"gap_on_avg_home": 214206,
|
||||
"dominant_type": "Terraced",
|
||||
"build_year": 1940,
|
||||
"good_secondary_catchments": 7.8,
|
||||
"station_km": 0.73,
|
||||
"distance_km": 2.02
|
||||
},
|
||||
"map_query": "lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": false,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__ha7-2-vs-ha3-0.json
Normal file
42
analysis/out/findings/cheaper-twin__ha7-2-vs-ha3-0.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/ha7-2-vs-ha3-0",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/ha7-2-vs-ha3-0",
|
||||
"title": "Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m\u00b2",
|
||||
"hook": "\u00a3106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart",
|
||||
"shocking_number": "17%",
|
||||
"pricey": {
|
||||
"sector": "HA7 2",
|
||||
"name": "Stanmore",
|
||||
"label": "Stanmore (HA7 2)",
|
||||
"named": true,
|
||||
"est_psqm": 6834,
|
||||
"n": 2775
|
||||
},
|
||||
"twin": {
|
||||
"sector": "HA3 0",
|
||||
"name": "Kenton",
|
||||
"label": "Kenton (HA3 0)",
|
||||
"named": true,
|
||||
"est_psqm": 5646,
|
||||
"n": 3122
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 17.4,
|
||||
"gap_per_sqm": 1188,
|
||||
"gap_on_90sqm": 106920,
|
||||
"gap_on_avg_home": 108108,
|
||||
"dominant_type": "Semi-Detached",
|
||||
"build_year": 1940,
|
||||
"good_secondary_catchments": 2.9,
|
||||
"station_km": 1.31,
|
||||
"distance_km": 2.57
|
||||
},
|
||||
"map_query": "lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": false,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__ig8-7-vs-ig6-2.json
Normal file
42
analysis/out/findings/cheaper-twin__ig8-7-vs-ig6-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/ig8-7-vs-ig6-2",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/ig8-7-vs-ig6-2",
|
||||
"title": "Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m\u00b2",
|
||||
"hook": "\u00a3164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart",
|
||||
"shocking_number": "26%",
|
||||
"pricey": {
|
||||
"sector": "IG8 7",
|
||||
"name": "Woodford Green",
|
||||
"label": "Woodford Green (IG8 7)",
|
||||
"named": true,
|
||||
"est_psqm": 7148,
|
||||
"n": 2965
|
||||
},
|
||||
"twin": {
|
||||
"sector": "IG6 2",
|
||||
"name": "Barkingside",
|
||||
"label": "Barkingside (IG6 2)",
|
||||
"named": true,
|
||||
"est_psqm": 5325,
|
||||
"n": 4423
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 25.5,
|
||||
"gap_per_sqm": 1823,
|
||||
"gap_on_90sqm": 164070,
|
||||
"gap_on_avg_home": 143105,
|
||||
"dominant_type": "Terraced",
|
||||
"build_year": 1958,
|
||||
"good_secondary_catchments": 2.8,
|
||||
"station_km": 0.58,
|
||||
"distance_km": 2.98
|
||||
},
|
||||
"map_query": "lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": false,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__l16-7-vs-l14-6.json
Normal file
42
analysis/out/findings/cheaper-twin__l16-7-vs-l14-6.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/l16-7-vs-l14-6",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/l16-7-vs-l14-6",
|
||||
"title": "Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m\u00b2",
|
||||
"hook": "\u00a3106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart",
|
||||
"shocking_number": "30%",
|
||||
"pricey": {
|
||||
"sector": "L16 7",
|
||||
"name": "Childwall",
|
||||
"label": "Childwall (L16 7)",
|
||||
"named": true,
|
||||
"est_psqm": 4026,
|
||||
"n": 500
|
||||
},
|
||||
"twin": {
|
||||
"sector": "L14 6",
|
||||
"name": "Broadgreen",
|
||||
"label": "Broadgreen (L14 6)",
|
||||
"named": true,
|
||||
"est_psqm": 2840,
|
||||
"n": 809
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 29.5,
|
||||
"gap_per_sqm": 1186,
|
||||
"gap_on_90sqm": 106740,
|
||||
"gap_on_avg_home": 117414,
|
||||
"dominant_type": "Semi-Detached",
|
||||
"build_year": 1940,
|
||||
"good_secondary_catchments": 5.1,
|
||||
"station_km": 1.22,
|
||||
"distance_km": 1.88
|
||||
},
|
||||
"map_query": "lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": false,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__m40-5-vs-m9-4.json
Normal file
42
analysis/out/findings/cheaper-twin__m40-5-vs-m9-4.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/m40-5-vs-m9-4",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/m40-5-vs-m9-4",
|
||||
"title": "Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m\u00b2",
|
||||
"hook": "\u00a3106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart",
|
||||
"shocking_number": "42%",
|
||||
"pricey": {
|
||||
"sector": "M40 5",
|
||||
"name": "Newton Heath",
|
||||
"label": "Newton Heath (M40 5)",
|
||||
"named": true,
|
||||
"est_psqm": 2812,
|
||||
"n": 1632
|
||||
},
|
||||
"twin": {
|
||||
"sector": "M9 4",
|
||||
"name": "Harpurhey",
|
||||
"label": "Harpurhey (M9 4)",
|
||||
"named": true,
|
||||
"est_psqm": 1626,
|
||||
"n": 3530
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 42.2,
|
||||
"gap_per_sqm": 1186,
|
||||
"gap_on_90sqm": 106740,
|
||||
"gap_on_avg_home": 91915,
|
||||
"dominant_type": "Terraced",
|
||||
"build_year": 1958,
|
||||
"good_secondary_catchments": 2.6,
|
||||
"station_km": 0.72,
|
||||
"distance_km": 1.18
|
||||
},
|
||||
"map_query": "lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": false,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__rm14-2-vs-rm12-5.json
Normal file
42
analysis/out/findings/cheaper-twin__rm14-2-vs-rm12-5.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/rm14-2-vs-rm12-5",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/rm14-2-vs-rm12-5",
|
||||
"title": "Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m\u00b2",
|
||||
"hook": "\u00a3115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart",
|
||||
"shocking_number": "20%",
|
||||
"pricey": {
|
||||
"sector": "RM14 2",
|
||||
"name": "Upminster",
|
||||
"label": "Upminster (RM14 2)",
|
||||
"named": true,
|
||||
"est_psqm": 6360,
|
||||
"n": 3026
|
||||
},
|
||||
"twin": {
|
||||
"sector": "RM12 5",
|
||||
"name": "Hornchurch",
|
||||
"label": "Hornchurch (RM12 5)",
|
||||
"named": true,
|
||||
"est_psqm": 5079,
|
||||
"n": 3133
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 20.1,
|
||||
"gap_per_sqm": 1281,
|
||||
"gap_on_90sqm": 115290,
|
||||
"gap_on_avg_home": 111447,
|
||||
"dominant_type": "Semi-Detached",
|
||||
"build_year": 1940,
|
||||
"good_secondary_catchments": 3.7,
|
||||
"station_km": 0.77,
|
||||
"distance_km": 2.99
|
||||
},
|
||||
"map_query": "lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": false,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__se28-8-vs-da18-4.json
Normal file
42
analysis/out/findings/cheaper-twin__se28-8-vs-da18-4.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/se28-8-vs-da18-4",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/se28-8-vs-da18-4",
|
||||
"title": "SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m\u00b2",
|
||||
"hook": "\u00a3129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart",
|
||||
"shocking_number": "30%",
|
||||
"pricey": {
|
||||
"sector": "SE28 8",
|
||||
"name": null,
|
||||
"label": "SE28 8",
|
||||
"named": false,
|
||||
"est_psqm": 4850,
|
||||
"n": 5033
|
||||
},
|
||||
"twin": {
|
||||
"sector": "DA18 4",
|
||||
"name": null,
|
||||
"label": "DA18 4",
|
||||
"named": false,
|
||||
"est_psqm": 3409,
|
||||
"n": 1063
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 29.7,
|
||||
"gap_per_sqm": 1441,
|
||||
"gap_on_90sqm": 129690,
|
||||
"gap_on_avg_home": 104112,
|
||||
"dominant_type": "Terraced",
|
||||
"build_year": 1993,
|
||||
"good_secondary_catchments": 2.8,
|
||||
"station_km": 1.39,
|
||||
"distance_km": 1.72
|
||||
},
|
||||
"map_query": "lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": true,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__sw1x-8-vs-sw7-2.json
Normal file
42
analysis/out/findings/cheaper-twin__sw1x-8-vs-sw7-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/sw1x-8-vs-sw7-2",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/sw1x-8-vs-sw7-2",
|
||||
"title": "SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m\u00b2",
|
||||
"hook": "\u00a31,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart",
|
||||
"shocking_number": "42%",
|
||||
"pricey": {
|
||||
"sector": "SW1X 8",
|
||||
"name": null,
|
||||
"label": "SW1X 8",
|
||||
"named": false,
|
||||
"est_psqm": 26735,
|
||||
"n": 1410
|
||||
},
|
||||
"twin": {
|
||||
"sector": "SW7 2",
|
||||
"name": null,
|
||||
"label": "SW7 2",
|
||||
"named": false,
|
||||
"est_psqm": 15611,
|
||||
"n": 1126
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 41.6,
|
||||
"gap_per_sqm": 11124,
|
||||
"gap_on_90sqm": 1001160,
|
||||
"gap_on_avg_home": 1301508,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1890,
|
||||
"good_secondary_catchments": 3.7,
|
||||
"station_km": 0.43,
|
||||
"distance_km": 1.31
|
||||
},
|
||||
"map_query": "lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": true,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__tw12-3-vs-kt8-1.json
Normal file
42
analysis/out/findings/cheaper-twin__tw12-3-vs-kt8-1.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/tw12-3-vs-kt8-1",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/tw12-3-vs-kt8-1",
|
||||
"title": "Hampton vs East Molesey: the same terraced house, about 19% cheaper per m\u00b2",
|
||||
"hook": "\u00a3120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart",
|
||||
"shocking_number": "19%",
|
||||
"pricey": {
|
||||
"sector": "TW12 3",
|
||||
"name": "Hampton",
|
||||
"label": "Hampton (TW12 3)",
|
||||
"named": true,
|
||||
"est_psqm": 7042,
|
||||
"n": 2527
|
||||
},
|
||||
"twin": {
|
||||
"sector": "KT8 1",
|
||||
"name": "East Molesey",
|
||||
"label": "East Molesey (KT8 1)",
|
||||
"named": true,
|
||||
"est_psqm": 5708,
|
||||
"n": 1567
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 18.9,
|
||||
"gap_per_sqm": 1334,
|
||||
"gap_on_90sqm": 120060,
|
||||
"gap_on_avg_home": 107053,
|
||||
"dominant_type": "Terraced",
|
||||
"build_year": 1979,
|
||||
"good_secondary_catchments": 3.2,
|
||||
"station_km": 1.27,
|
||||
"distance_km": 2.23
|
||||
},
|
||||
"map_query": "lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": false,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__tw2-7-vs-tw3-2.json
Normal file
42
analysis/out/findings/cheaper-twin__tw2-7-vs-tw3-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/tw2-7-vs-tw3-2",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/tw2-7-vs-tw3-2",
|
||||
"title": "Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m\u00b2",
|
||||
"hook": "\u00a3121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart",
|
||||
"shocking_number": "19%",
|
||||
"pricey": {
|
||||
"sector": "TW2 7",
|
||||
"name": "Twickenham",
|
||||
"label": "Twickenham (TW2 7)",
|
||||
"named": true,
|
||||
"est_psqm": 6971,
|
||||
"n": 3377
|
||||
},
|
||||
"twin": {
|
||||
"sector": "TW3 2",
|
||||
"name": "Hounslow",
|
||||
"label": "Hounslow (TW3 2)",
|
||||
"named": true,
|
||||
"est_psqm": 5620,
|
||||
"n": 2964
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 19.4,
|
||||
"gap_per_sqm": 1351,
|
||||
"gap_on_90sqm": 121590,
|
||||
"gap_on_avg_home": 113821,
|
||||
"dominant_type": "Semi-Detached",
|
||||
"build_year": 1940,
|
||||
"good_secondary_catchments": 5.4,
|
||||
"station_km": 0.59,
|
||||
"distance_km": 1.0
|
||||
},
|
||||
"map_query": "lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": false,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__w1j-7-vs-sw7-3.json
Normal file
42
analysis/out/findings/cheaper-twin__w1j-7-vs-sw7-3.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/w1j-7-vs-sw7-3",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/w1j-7-vs-sw7-3",
|
||||
"title": "W1J 7 vs SW7 3: the same flat, about 41% cheaper per m\u00b2",
|
||||
"hook": "\u00a31,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart",
|
||||
"shocking_number": "41%",
|
||||
"pricey": {
|
||||
"sector": "W1J 7",
|
||||
"name": "Mayfair",
|
||||
"label": "Mayfair (W1J 7)",
|
||||
"named": true,
|
||||
"est_psqm": 32986,
|
||||
"n": 724
|
||||
},
|
||||
"twin": {
|
||||
"sector": "SW7 3",
|
||||
"name": null,
|
||||
"label": "SW7 3",
|
||||
"named": false,
|
||||
"est_psqm": 19392,
|
||||
"n": 2581
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 41.2,
|
||||
"gap_per_sqm": 13594,
|
||||
"gap_on_90sqm": 1223460,
|
||||
"gap_on_avg_home": 1077324,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1914,
|
||||
"good_secondary_catchments": 2.0,
|
||||
"station_km": 0.3,
|
||||
"distance_km": 2.6
|
||||
},
|
||||
"map_query": "lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": true,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__w1j-8-vs-sw1a-2.json
Normal file
42
analysis/out/findings/cheaper-twin__w1j-8-vs-sw1a-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/w1j-8-vs-sw1a-2",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/w1j-8-vs-sw1a-2",
|
||||
"title": "W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m\u00b2",
|
||||
"hook": "\u00a3916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart",
|
||||
"shocking_number": "37%",
|
||||
"pricey": {
|
||||
"sector": "W1J 8",
|
||||
"name": "Mayfair",
|
||||
"label": "Mayfair (W1J 8)",
|
||||
"named": true,
|
||||
"est_psqm": 27270,
|
||||
"n": 295
|
||||
},
|
||||
"twin": {
|
||||
"sector": "SW1A 2",
|
||||
"name": null,
|
||||
"label": "SW1A 2",
|
||||
"named": false,
|
||||
"est_psqm": 17088,
|
||||
"n": 261
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 37.3,
|
||||
"gap_per_sqm": 10182,
|
||||
"gap_on_90sqm": 916380,
|
||||
"gap_on_avg_home": 1089474,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 2000,
|
||||
"good_secondary_catchments": 2.0,
|
||||
"station_km": 0.18,
|
||||
"distance_km": 1.31
|
||||
},
|
||||
"map_query": "lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": true,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__w1k-2-vs-sw1x-0.json
Normal file
42
analysis/out/findings/cheaper-twin__w1k-2-vs-sw1x-0.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/w1k-2-vs-sw1x-0",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/w1k-2-vs-sw1x-0",
|
||||
"title": "W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m\u00b2",
|
||||
"hook": "\u00a3978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart",
|
||||
"shocking_number": "32%",
|
||||
"pricey": {
|
||||
"sector": "W1K 2",
|
||||
"name": "Mayfair",
|
||||
"label": "Mayfair (W1K 2)",
|
||||
"named": true,
|
||||
"est_psqm": 34362,
|
||||
"n": 591
|
||||
},
|
||||
"twin": {
|
||||
"sector": "SW1X 0",
|
||||
"name": null,
|
||||
"label": "SW1X 0",
|
||||
"named": false,
|
||||
"est_psqm": 23489,
|
||||
"n": 1606
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 31.6,
|
||||
"gap_per_sqm": 10873,
|
||||
"gap_on_90sqm": 978570,
|
||||
"gap_on_avg_home": 1293887,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1914,
|
||||
"good_secondary_catchments": 2.0,
|
||||
"station_km": 0.5,
|
||||
"distance_km": 1.62
|
||||
},
|
||||
"map_query": "lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": true,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__w1u-4-vs-nw1-4.json
Normal file
42
analysis/out/findings/cheaper-twin__w1u-4-vs-nw1-4.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/w1u-4-vs-nw1-4",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/w1u-4-vs-nw1-4",
|
||||
"title": "Marylebone vs Camden: the same flat, about 43% cheaper per m\u00b2",
|
||||
"hook": "\u00a3942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart",
|
||||
"shocking_number": "43%",
|
||||
"pricey": {
|
||||
"sector": "W1U 4",
|
||||
"name": "Marylebone",
|
||||
"label": "Marylebone (W1U 4)",
|
||||
"named": true,
|
||||
"est_psqm": 24238,
|
||||
"n": 984
|
||||
},
|
||||
"twin": {
|
||||
"sector": "NW1 4",
|
||||
"name": "Camden",
|
||||
"label": "Camden (NW1 4)",
|
||||
"named": true,
|
||||
"est_psqm": 13766,
|
||||
"n": 1340
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 43.2,
|
||||
"gap_per_sqm": 10472,
|
||||
"gap_on_90sqm": 942480,
|
||||
"gap_on_avg_home": 759220,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1940,
|
||||
"good_secondary_catchments": 1.0,
|
||||
"station_km": 0.44,
|
||||
"distance_km": 0.97
|
||||
},
|
||||
"map_query": "lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": false,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
42
analysis/out/findings/cheaper-twin__wc2a-2-vs-ec2a-2.json
Normal file
42
analysis/out/findings/cheaper-twin__wc2a-2-vs-ec2a-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"slug": "cheaper-twin/wc2a-2-vs-ec2a-2",
|
||||
"type": "cheaper_twin",
|
||||
"page_path": "/cheaper-twin/wc2a-2-vs-ec2a-2",
|
||||
"title": "WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m\u00b2",
|
||||
"hook": "\u00a3981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart",
|
||||
"shocking_number": "43%",
|
||||
"pricey": {
|
||||
"sector": "WC2A 2",
|
||||
"name": null,
|
||||
"label": "WC2A 2",
|
||||
"named": false,
|
||||
"est_psqm": 25482,
|
||||
"n": 254
|
||||
},
|
||||
"twin": {
|
||||
"sector": "EC2A 2",
|
||||
"name": null,
|
||||
"label": "EC2A 2",
|
||||
"named": false,
|
||||
"est_psqm": 14576,
|
||||
"n": 772
|
||||
},
|
||||
"stats": {
|
||||
"gap_pct": 42.8,
|
||||
"gap_per_sqm": 10906,
|
||||
"gap_on_90sqm": 981540,
|
||||
"gap_on_avg_home": 834309,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 2019,
|
||||
"good_secondary_catchments": 2.0,
|
||||
"station_km": 0.42,
|
||||
"distance_km": 2.3
|
||||
},
|
||||
"map_query": "lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"map_url": "https://perfect-postcode.co.uk/?lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), good-school catchment provision, station access, deprivation/tenure, education, age and home size, so the price gap reflects a name premium, not a different kind of area. Estimates, not valuations; aggregated to sector, never address-level.",
|
||||
"needs_name_check": true,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
28
analysis/out/findings/square-metres-per-100k.json
Normal file
28
analysis/out/findings/square-metres-per-100k.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"slug": "square-metres-per-100k",
|
||||
"type": "national_table",
|
||||
"page_path": "/square-metres-per-100k",
|
||||
"title": "How many square metres \u00a3100,000 buys across England",
|
||||
"shocking_number": "152 m\u00b2 vs 3 m\u00b2",
|
||||
"hook": "\u00a3100k buys ~152 m\u00b2 of floor space in BD21 3 but only ~3 m\u00b2 in Mayfair (W1K 2)",
|
||||
"stats": {
|
||||
"best": {
|
||||
"sector": "BD21 3",
|
||||
"est_psqm": 660,
|
||||
"sqm_per_100k": 151.6,
|
||||
"n": 1377
|
||||
},
|
||||
"dearest": {
|
||||
"sector": "W1K 2",
|
||||
"est_psqm": 34362,
|
||||
"sqm_per_100k": 2.9,
|
||||
"n": 591
|
||||
},
|
||||
"n_sectors": 7560
|
||||
},
|
||||
"map_query": "zoom=6&filter=Est.%20price%20per%20sqm:0:4000",
|
||||
"methodology": "100000 \u00f7 median estimated \u00a3/m\u00b2, per England postcode sector with sufficient sales.",
|
||||
"needs_name_check": true,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||
}
|
||||
23
analysis/out/video_scripts/INDEX.md
Normal file
23
analysis/out/video_scripts/INDEX.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# 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 |
|
||||
|-----|------|------|
|
||||
| Beckenham → Croydon | 31% / £201,870 | `br3-3-vs-cr0-7.md` |
|
||||
| Woodford Green → Barkingside | 26% / £164,070 | `ig8-7-vs-ig6-2.md` |
|
||||
| SE28 8 → DA18 4 | 30% / £129,690 | `se28-8-vs-da18-4.md` |
|
||||
| Twickenham → Hounslow | 19% / £121,590 | `tw2-7-vs-tw3-2.md` |
|
||||
| Hampton → East Molesey | 19% / £120,060 | `tw12-3-vs-kt8-1.md` |
|
||||
| Upminster → Hornchurch | 20% / £115,290 | `rm14-2-vs-rm12-5.md` |
|
||||
| Stanmore → Kenton | 17% / £106,920 | `ha7-2-vs-ha3-0.md` |
|
||||
| Childwall → Broadgreen | 30% / £106,740 | `l16-7-vs-l14-6.md` |
|
||||
| Newton Heath → Harpurhey | 42% / £106,740 | `m40-5-vs-m9-4.md` |
|
||||
| Mayfair → SW7 3 | 41% / £1,223,460 | `w1j-7-vs-sw7-3.md` |
|
||||
| SW1X 8 → SW7 2 | 42% / £1,001,160 | `sw1x-8-vs-sw7-2.md` |
|
||||
| WC2A 2 → EC2A 2 | 43% / £981,540 | `wc2a-2-vs-ec2a-2.md` |
|
||||
| Mayfair → SW1X 0 | 32% / £978,570 | `w1k-2-vs-sw1x-0.md` |
|
||||
| Marylebone → Camden | 43% / £942,480 | `w1u-4-vs-nw1-4.md` |
|
||||
| Mayfair → SW1A 2 | 37% / £916,380 | `w1j-8-vs-sw1a-2.md` |
|
||||
79
analysis/out/video_scripts/br3-3-vs-cr0-7.md
Normal file
79
analysis/out/video_scripts/br3-3-vs-cr0-7.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Beckenham vs Croydon
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/br3-3-vs-cr0-7 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£201,870 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£201,870 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 | Note the dominant type (terraced houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Beckenham £7,153 vs Croydon £4,910. | Caption: '31% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Beckenham. And this is Croydon, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Beckenham: £7,153. Croydon: £4,910. That's 31% cheaper, about £201,870 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Beckenham vs Croydon
|
||||
- Same station. Same schools.
|
||||
- £201,870 cheaper
|
||||
- Same terraced, ~1940
|
||||
- 31% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. Beckenham vs Croydon: the same terraced house, about 31% cheaper per m²
|
||||
2. Beckenham vs Croydon: same station, same schools, £201,870 cheaper
|
||||
3. Why Croydon is the smart-money version of Beckenham (31% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£201,870 cheaper` + the two names `Beckenham → Croydon`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Beckenham and Croydon share a station, a school catchment and the same era of housing, but Croydon costs about 31% less per square metre (£201,870 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Beckenham & Croydon)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Beckenham £7,153 → Croydon £4,910) + caption '31% 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
|
||||
{
|
||||
"name": "twin-br3-3-vs-cr0-7",
|
||||
"city": "london",
|
||||
"promptText": "Best value terraceds near Beckenham: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
5200
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "Croydon: same life, 31% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/ha7-2-vs-ha3-0.md
Normal file
79
analysis/out/video_scripts/ha7-2-vs-ha3-0.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Stanmore vs Kenton
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/ha7-2-vs-ha3-0 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£106,920 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£106,920 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 | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Stanmore £6,834 vs Kenton £5,646. | Caption: '17% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Stanmore. And this is Kenton, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Stanmore: £6,834. Kenton: £5,646. That's 17% cheaper, about £106,920 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Stanmore vs Kenton
|
||||
- Same station. Same schools.
|
||||
- £106,920 cheaper
|
||||
- Same semi-detached, ~1940
|
||||
- 17% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m²
|
||||
2. Stanmore vs Kenton: same station, same schools, £106,920 cheaper
|
||||
3. Why Kenton is the smart-money version of Stanmore (17% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£106,920 cheaper` + the two names `Stanmore → Kenton`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Stanmore and Kenton share a station, a school catchment and the same era of housing, but Kenton costs about 17% less per square metre (£106,920 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Stanmore & Kenton)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Stanmore £6,834 → Kenton £5,646) + caption '17% 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
|
||||
{
|
||||
"name": "twin-ha7-2-vs-ha3-0",
|
||||
"city": "london",
|
||||
"promptText": "Best value semi-detacheds near Stanmore: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
5900
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "Kenton: same life, 17% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/ig8-7-vs-ig6-2.md
Normal file
79
analysis/out/video_scripts/ig8-7-vs-ig6-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Woodford Green vs Barkingside
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/ig8-7-vs-ig6-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£164,070 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£164,070 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 | Note the dominant type (terraced houses) and build era (~1958). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Woodford Green £7,148 vs Barkingside £5,325. | Caption: '26% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Woodford Green. And this is Barkingside, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1958. On every measure that moves price, they're twins. But watch the price per square metre. Woodford Green: £7,148. Barkingside: £5,325. That's 26% cheaper, about £164,070 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Woodford Green vs Barkingside
|
||||
- Same station. Same schools.
|
||||
- £164,070 cheaper
|
||||
- Same terraced, ~1958
|
||||
- 26% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m²
|
||||
2. Woodford Green vs Barkingside: same station, same schools, £164,070 cheaper
|
||||
3. Why Barkingside is the smart-money version of Woodford Green (26% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£164,070 cheaper` + the two names `Woodford Green → Barkingside`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Woodford Green and Barkingside share a station, a school catchment and the same era of housing, but Barkingside costs about 26% less per square metre (£164,070 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Woodford Green & Barkingside)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Woodford Green £7,148 → Barkingside £5,325) + caption '26% 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
|
||||
{
|
||||
"name": "twin-ig8-7-vs-ig6-2",
|
||||
"city": "london",
|
||||
"promptText": "Best value terraceds near Woodford Green: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
5600
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "Barkingside: same life, 26% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/l16-7-vs-l14-6.md
Normal file
79
analysis/out/video_scripts/l16-7-vs-l14-6.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Childwall vs Broadgreen
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/l16-7-vs-l14-6 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£106,740 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£106,740 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 | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Childwall £4,026 vs Broadgreen £2,840. | Caption: '30% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Childwall. And this is Broadgreen, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Childwall: £4,026. Broadgreen: £2,840. That's 30% cheaper, about £106,740 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Childwall vs Broadgreen
|
||||
- Same station. Same schools.
|
||||
- £106,740 cheaper
|
||||
- Same semi-detached, ~1940
|
||||
- 30% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m²
|
||||
2. Childwall vs Broadgreen: same station, same schools, £106,740 cheaper
|
||||
3. Why Broadgreen is the smart-money version of Childwall (30% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£106,740 cheaper` + the two names `Childwall → Broadgreen`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Childwall and Broadgreen share a station, a school catchment and the same era of housing, but Broadgreen costs about 30% less per square metre (£106,740 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Childwall & Broadgreen)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Childwall £4,026 → Broadgreen £2,840) + caption '30% 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
|
||||
{
|
||||
"name": "twin-l16-7-vs-l14-6",
|
||||
"city": "london",
|
||||
"promptText": "Best value semi-detacheds near Childwall: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
3000
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "Broadgreen: same life, 30% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/m40-5-vs-m9-4.md
Normal file
79
analysis/out/video_scripts/m40-5-vs-m9-4.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Newton Heath vs Harpurhey
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/m40-5-vs-m9-4 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£106,740 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£106,740 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 | Note the dominant type (terraced houses) and build era (~1958). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Newton Heath £2,812 vs Harpurhey £1,626. | Caption: '42% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Newton Heath. And this is Harpurhey, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1958. On every measure that moves price, they're twins. But watch the price per square metre. Newton Heath: £2,812. Harpurhey: £1,626. That's 42% cheaper, about £106,740 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Newton Heath vs Harpurhey
|
||||
- Same station. Same schools.
|
||||
- £106,740 cheaper
|
||||
- Same terraced, ~1958
|
||||
- 42% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m²
|
||||
2. Newton Heath vs Harpurhey: same station, same schools, £106,740 cheaper
|
||||
3. Why Harpurhey is the smart-money version of Newton Heath (42% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£106,740 cheaper` + the two names `Newton Heath → Harpurhey`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Newton Heath and Harpurhey share a station, a school catchment and the same era of housing, but Harpurhey costs about 42% less per square metre (£106,740 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Newton Heath & Harpurhey)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Newton Heath £2,812 → Harpurhey £1,626) + caption '42% 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
|
||||
{
|
||||
"name": "twin-m40-5-vs-m9-4",
|
||||
"city": "manchester",
|
||||
"promptText": "Best value terraceds near Newton Heath: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
1700
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "Harpurhey: same life, 42% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/rm14-2-vs-rm12-5.md
Normal file
79
analysis/out/video_scripts/rm14-2-vs-rm12-5.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Upminster vs Hornchurch
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/rm14-2-vs-rm12-5 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£115,290 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£115,290 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 | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Upminster £6,360 vs Hornchurch £5,079. | Caption: '20% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Upminster. And this is Hornchurch, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Upminster: £6,360. Hornchurch: £5,079. That's 20% cheaper, about £115,290 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Upminster vs Hornchurch
|
||||
- Same station. Same schools.
|
||||
- £115,290 cheaper
|
||||
- Same semi-detached, ~1940
|
||||
- 20% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m²
|
||||
2. Upminster vs Hornchurch: same station, same schools, £115,290 cheaper
|
||||
3. Why Hornchurch is the smart-money version of Upminster (20% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£115,290 cheaper` + the two names `Upminster → Hornchurch`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Upminster and Hornchurch share a station, a school catchment and the same era of housing, but Hornchurch costs about 20% less per square metre (£115,290 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Upminster & Hornchurch)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Upminster £6,360 → Hornchurch £5,079) + caption '20% 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
|
||||
{
|
||||
"name": "twin-rm14-2-vs-rm12-5",
|
||||
"city": "london",
|
||||
"promptText": "Best value semi-detacheds near Upminster: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
5300
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "Hornchurch: same life, 20% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/se28-8-vs-da18-4.md
Normal file
79
analysis/out/video_scripts/se28-8-vs-da18-4.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: SE28 8 vs DA18 4
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/se28-8-vs-da18-4 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£129,690 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£129,690 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 | Note the dominant type (terraced houses) and build era (~1993). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: SE28 8 £4,850 vs DA18 4 £3,409. | Caption: '30% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is SE28 8. And this is DA18 4, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1993. On every measure that moves price, they're twins. But watch the price per square metre. SE28 8: £4,850. DA18 4: £3,409. That's 30% cheaper, about £129,690 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- SE28 8 vs DA18 4
|
||||
- Same station. Same schools.
|
||||
- £129,690 cheaper
|
||||
- Same terraced, ~1993
|
||||
- 30% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m²
|
||||
2. SE28 8 vs DA18 4: same station, same schools, £129,690 cheaper
|
||||
3. Why DA18 4 is the smart-money version of SE28 8 (30% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£129,690 cheaper` + the two names `SE28 8 → DA18 4`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
SE28 8 and DA18 4 share a station, a school catchment and the same era of housing, but DA18 4 costs about 30% less per square metre (£129,690 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (SE28 8 & DA18 4)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (SE28 8 £4,850 → DA18 4 £3,409) + caption '30% 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
|
||||
{
|
||||
"name": "twin-se28-8-vs-da18-4",
|
||||
"city": "london",
|
||||
"promptText": "Best value terraceds near SE28 8: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
3600
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "DA18 4: same life, 30% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/sw1x-8-vs-sw7-2.md
Normal file
79
analysis/out/video_scripts/sw1x-8-vs-sw7-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: SW1X 8 vs SW7 2
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/sw1x-8-vs-sw7-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£1,001,160 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£1,001,160 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 | Note the dominant type (flats) and build era (~1890). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: SW1X 8 £26,735 vs SW7 2 £15,611. | Caption: '42% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is SW1X 8. And this is SW7 2, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1890. On every measure that moves price, they're twins. But watch the price per square metre. SW1X 8: £26,735. SW7 2: £15,611. That's 42% cheaper, about £1,001,160 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- SW1X 8 vs SW7 2
|
||||
- Same station. Same schools.
|
||||
- £1,001,160 cheaper
|
||||
- Same flats/maisonettes, ~1890
|
||||
- 42% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m²
|
||||
2. SW1X 8 vs SW7 2: same station, same schools, £1,001,160 cheaper
|
||||
3. Why SW7 2 is the smart-money version of SW1X 8 (42% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£1,001,160 cheaper` + the two names `SW1X 8 → SW7 2`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
SW1X 8 and SW7 2 share a station, a school catchment and the same era of housing, but SW7 2 costs about 42% less per square metre (£1,001,160 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (SW1X 8 & SW7 2)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (SW1X 8 £26,735 → SW7 2 £15,611) + caption '42% 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
|
||||
{
|
||||
"name": "twin-sw1x-8-vs-sw7-2",
|
||||
"city": "london",
|
||||
"promptText": "Best value flats/maisonettess near SW1X 8: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
16400
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "SW7 2: same life, 42% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/tw12-3-vs-kt8-1.md
Normal file
79
analysis/out/video_scripts/tw12-3-vs-kt8-1.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Hampton vs East Molesey
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/tw12-3-vs-kt8-1 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£120,060 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£120,060 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 | Note the dominant type (terraced houses) and build era (~1979). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Hampton £7,042 vs East Molesey £5,708. | Caption: '19% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Hampton. And this is East Molesey, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1979. On every measure that moves price, they're twins. But watch the price per square metre. Hampton: £7,042. East Molesey: £5,708. That's 19% cheaper, about £120,060 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Hampton vs East Molesey
|
||||
- Same station. Same schools.
|
||||
- £120,060 cheaper
|
||||
- Same terraced, ~1979
|
||||
- 19% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. Hampton vs East Molesey: the same terraced house, about 19% cheaper per m²
|
||||
2. Hampton vs East Molesey: same station, same schools, £120,060 cheaper
|
||||
3. Why East Molesey is the smart-money version of Hampton (19% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£120,060 cheaper` + the two names `Hampton → East Molesey`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Hampton and East Molesey share a station, a school catchment and the same era of housing, but East Molesey costs about 19% less per square metre (£120,060 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Hampton & East Molesey)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Hampton £7,042 → East Molesey £5,708) + caption '19% 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
|
||||
{
|
||||
"name": "twin-tw12-3-vs-kt8-1",
|
||||
"city": "london",
|
||||
"promptText": "Best value terraceds near Hampton: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
6000
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "East Molesey: same life, 19% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/tw2-7-vs-tw3-2.md
Normal file
79
analysis/out/video_scripts/tw2-7-vs-tw3-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Twickenham vs Hounslow
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/tw2-7-vs-tw3-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£121,590 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£121,590 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 | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Twickenham £6,971 vs Hounslow £5,620. | Caption: '19% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Twickenham. And this is Hounslow, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Twickenham: £6,971. Hounslow: £5,620. That's 19% cheaper, about £121,590 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Twickenham vs Hounslow
|
||||
- Same station. Same schools.
|
||||
- £121,590 cheaper
|
||||
- Same semi-detached, ~1940
|
||||
- 19% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m²
|
||||
2. Twickenham vs Hounslow: same station, same schools, £121,590 cheaper
|
||||
3. Why Hounslow is the smart-money version of Twickenham (19% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£121,590 cheaper` + the two names `Twickenham → Hounslow`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Twickenham and Hounslow share a station, a school catchment and the same era of housing, but Hounslow costs about 19% less per square metre (£121,590 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Twickenham & Hounslow)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Twickenham £6,971 → Hounslow £5,620) + caption '19% 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
|
||||
{
|
||||
"name": "twin-tw2-7-vs-tw3-2",
|
||||
"city": "london",
|
||||
"promptText": "Best value semi-detacheds near Twickenham: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
5900
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "Hounslow: same life, 19% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/w1j-7-vs-sw7-3.md
Normal file
79
analysis/out/video_scripts/w1j-7-vs-sw7-3.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Mayfair vs SW7 3
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1j-7-vs-sw7-3 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£1,223,460 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£1,223,460 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 | Note the dominant type (flats) and build era (~1914). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Mayfair £32,986 vs SW7 3 £19,392. | Caption: '41% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Mayfair. And this is SW7 3, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1914. On every measure that moves price, they're twins. But watch the price per square metre. Mayfair: £32,986. SW7 3: £19,392. That's 41% cheaper, about £1,223,460 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Mayfair vs SW7 3
|
||||
- Same station. Same schools.
|
||||
- £1,223,460 cheaper
|
||||
- Same flats/maisonettes, ~1914
|
||||
- 41% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. W1J 7 vs SW7 3: the same flat, about 41% cheaper per m²
|
||||
2. Mayfair vs SW7 3: same station, same schools, £1,223,460 cheaper
|
||||
3. Why SW7 3 is the smart-money version of Mayfair (41% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£1,223,460 cheaper` + the two names `Mayfair → SW7 3`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Mayfair and SW7 3 share a station, a school catchment and the same era of housing, but SW7 3 costs about 41% less per square metre (£1,223,460 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Mayfair & SW7 3)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Mayfair £32,986 → SW7 3 £19,392) + caption '41% 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
|
||||
{
|
||||
"name": "twin-w1j-7-vs-sw7-3",
|
||||
"city": "london",
|
||||
"promptText": "Best value flats/maisonettess near Mayfair: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
20400
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "SW7 3: same life, 41% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/w1j-8-vs-sw1a-2.md
Normal file
79
analysis/out/video_scripts/w1j-8-vs-sw1a-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Mayfair vs SW1A 2
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1j-8-vs-sw1a-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£916,380 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£916,380 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 | Note the dominant type (flats) and build era (~2000). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Mayfair £27,270 vs SW1A 2 £17,088. | Caption: '37% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Mayfair. And this is SW1A 2, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 2000. On every measure that moves price, they're twins. But watch the price per square metre. Mayfair: £27,270. SW1A 2: £17,088. That's 37% cheaper, about £916,380 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Mayfair vs SW1A 2
|
||||
- Same station. Same schools.
|
||||
- £916,380 cheaper
|
||||
- Same flats/maisonettes, ~2000
|
||||
- 37% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m²
|
||||
2. Mayfair vs SW1A 2: same station, same schools, £916,380 cheaper
|
||||
3. Why SW1A 2 is the smart-money version of Mayfair (37% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£916,380 cheaper` + the two names `Mayfair → SW1A 2`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Mayfair and SW1A 2 share a station, a school catchment and the same era of housing, but SW1A 2 costs about 37% less per square metre (£916,380 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Mayfair & SW1A 2)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Mayfair £27,270 → SW1A 2 £17,088) + caption '37% 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
|
||||
{
|
||||
"name": "twin-w1j-8-vs-sw1a-2",
|
||||
"city": "london",
|
||||
"promptText": "Best value flats/maisonettess near Mayfair: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
17900
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "SW1A 2: same life, 37% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/w1k-2-vs-sw1x-0.md
Normal file
79
analysis/out/video_scripts/w1k-2-vs-sw1x-0.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Mayfair vs SW1X 0
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1k-2-vs-sw1x-0 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£978,570 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£978,570 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 | Note the dominant type (flats) and build era (~1914). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Mayfair £34,362 vs SW1X 0 £23,489. | Caption: '32% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Mayfair. And this is SW1X 0, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1914. On every measure that moves price, they're twins. But watch the price per square metre. Mayfair: £34,362. SW1X 0: £23,489. That's 32% cheaper, about £978,570 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Mayfair vs SW1X 0
|
||||
- Same station. Same schools.
|
||||
- £978,570 cheaper
|
||||
- Same flats/maisonettes, ~1914
|
||||
- 32% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m²
|
||||
2. Mayfair vs SW1X 0: same station, same schools, £978,570 cheaper
|
||||
3. Why SW1X 0 is the smart-money version of Mayfair (32% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£978,570 cheaper` + the two names `Mayfair → SW1X 0`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Mayfair and SW1X 0 share a station, a school catchment and the same era of housing, but SW1X 0 costs about 32% less per square metre (£978,570 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Mayfair & SW1X 0)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Mayfair £34,362 → SW1X 0 £23,489) + caption '32% 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
|
||||
{
|
||||
"name": "twin-w1k-2-vs-sw1x-0",
|
||||
"city": "london",
|
||||
"promptText": "Best value flats/maisonettess near Mayfair: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
24700
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "SW1X 0: same life, 32% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/w1u-4-vs-nw1-4.md
Normal file
79
analysis/out/video_scripts/w1u-4-vs-nw1-4.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: Marylebone vs Camden
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1u-4-vs-nw1-4 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£942,480 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£942,480 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 | Note the dominant type (flats) and build era (~1940). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Marylebone £24,238 vs Camden £13,766. | Caption: '43% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is Marylebone. And this is Camden, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Marylebone: £24,238. Camden: £13,766. That's 43% cheaper, about £942,480 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- Marylebone vs Camden
|
||||
- Same station. Same schools.
|
||||
- £942,480 cheaper
|
||||
- Same flats/maisonettes, ~1940
|
||||
- 43% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. Marylebone vs Camden: the same flat, about 43% cheaper per m²
|
||||
2. Marylebone vs Camden: same station, same schools, £942,480 cheaper
|
||||
3. Why Camden is the smart-money version of Marylebone (43% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£942,480 cheaper` + the two names `Marylebone → Camden`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
Marylebone and Camden share a station, a school catchment and the same era of housing, but Camden costs about 43% less per square metre (£942,480 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (Marylebone & Camden)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (Marylebone £24,238 → Camden £13,766) + caption '43% 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
|
||||
{
|
||||
"name": "twin-w1u-4-vs-nw1-4",
|
||||
"city": "london",
|
||||
"promptText": "Best value flats/maisonettess near Marylebone: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
14500
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "Camden: same life, 43% cheaper."
|
||||
}
|
||||
```
|
||||
79
analysis/out/video_scripts/wc2a-2-vs-ec2a-2.md
Normal file
79
analysis/out/video_scripts/wc2a-2-vs-ec2a-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Video kit: WC2A 2 vs EC2A 2
|
||||
|
||||
**Page:** https://perfect-postcode.co.uk/cheaper-twin/wc2a-2-vs-ec2a-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||
|
||||
## 🎬 Map URL to record (open this, hit record)
|
||||
`https://perfect-postcode.co.uk/?lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
*(filters are pre-applied so the value is on screen immediately)*
|
||||
|
||||
## Hook (first 2 seconds, on screen + said)
|
||||
**"£981,540 cheaper. Same station. Same schools."**
|
||||
|
||||
## Shot list
|
||||
| Time | Beat | What to show | On-screen |
|
||||
|------|------|--------------|-----------|
|
||||
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£981,540 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 | Note the dominant type (flats) and build era (~2019). | Caption: 'Same homes.' |
|
||||
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: WC2A 2 £25,482 vs EC2A 2 £14,576. | Caption: '43% less per m²'. |
|
||||
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||
|
||||
## Narration (human voiceover, never raw TTS for a property audience)
|
||||
> This is WC2A 2. And this is EC2A 2, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 2019. On every measure that moves price, they're twins. But watch the price per square metre. WC2A 2: £25,482. EC2A 2: £14,576. That's 43% cheaper, about £981,540 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||
|
||||
## Captions (≤6 words, sound-off)
|
||||
- WC2A 2 vs EC2A 2
|
||||
- Same station. Same schools.
|
||||
- £981,540 cheaper
|
||||
- Same flats/maisonettes, ~2019
|
||||
- 43% less per m²
|
||||
- Find your cheaper twin, free
|
||||
|
||||
## YouTube
|
||||
**Title options:**
|
||||
1. WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m²
|
||||
2. WC2A 2 vs EC2A 2: same station, same schools, £981,540 cheaper
|
||||
3. Why EC2A 2 is the smart-money version of WC2A 2 (43% less per m²)
|
||||
|
||||
**Thumbnail text:** big number `£981,540 cheaper` + the two names `WC2A 2 → EC2A 2`
|
||||
|
||||
**Description (paste as-is):**
|
||||
```
|
||||
https://perfect-postcode.co.uk/?lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11
|
||||
|
||||
WC2A 2 and EC2A 2 share a station, a school catchment and the same era of housing, but EC2A 2 costs about 43% less per square metre (£981,540 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||
|
||||
0:00 The two postcodes (WC2A 2 & EC2A 2)
|
||||
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)
|
||||
|
||||
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||
```
|
||||
|
||||
## 9:16 Short (cut from the same recording)
|
||||
First 3 seconds: the £/m² reveal (WC2A 2 £25,482 → EC2A 2 £14,576) + caption '43% 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
|
||||
{
|
||||
"name": "twin-wc2a-2-vs-ec2a-2",
|
||||
"city": "london",
|
||||
"promptText": "Best value flats/maisonettess near WC2A 2: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [
|
||||
0,
|
||||
15300
|
||||
],
|
||||
"Good+ secondary school catchments": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
"outroLine": "EC2A 2: same life, 43% cheaper."
|
||||
}
|
||||
```
|
||||
|
|
@ -30,7 +30,7 @@ services:
|
|||
- cargo-home:/usr/local/cargo
|
||||
- cargo-target:/app/server-rs/target
|
||||
environment:
|
||||
# Fallback only — the binary uses jemalloc as its global allocator
|
||||
# Fallback only: the binary uses jemalloc as its global allocator
|
||||
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
||||
MALLOC_ARENA_MAX: "2"
|
||||
# Dev only: spill the large property arrays (feature matrix +
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# Finder — property listing scraper
|
||||
# Finder: property listing scraper
|
||||
|
||||
Scrapes Greater-London sale listings from **Rightmove**, **OnTheMarket**, and
|
||||
**Zoopla**, recovers each property's true full postcode, and writes a single
|
||||
parquet (`data/online_listings_buy.parquet`) that the rest of the app consumes
|
||||
(after a separate enrich step — see [Output](#output)).
|
||||
(after a separate enrich step, see [Output](#output)).
|
||||
|
||||
`main.py` is the only entry point; everything else is library code.
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ parquet (`data/online_listings_buy.parquet`) that the rest of the app consumes
|
|||
## How it works (and why it's careful about postcodes)
|
||||
|
||||
Every portal's **search** API exposes only an *outcode*-level address (e.g.
|
||||
`"…, London, SW9"`) plus map coordinates — never the full unit postcode. The
|
||||
`"…, London, SW9"`) plus map coordinates, never the full unit postcode. The
|
||||
full postcode lives on each listing's **detail page**, so the scraper fetches
|
||||
detail pages to recover it, and only trusts a detail postcode when its outcode
|
||||
agrees with the coordinate-nearest postcode (so a stale/wrong value can never
|
||||
|
|
@ -22,7 +22,7 @@ falls back to the coordinate-nearest postcode. See the module docstrings in
|
|||
|
||||
Detail fetching is the dominant cost, so it is:
|
||||
|
||||
- **cached across runs** — `data/detail_cache/{source}.json` maps listing id →
|
||||
- **cached across runs**: `data/detail_cache/{source}.json` maps listing id →
|
||||
recovered postcode; a re-run only fetches *newly-appeared* listings;
|
||||
- **fetched concurrently** for the HTTP portals (Rightmove, OnTheMarket), bounded
|
||||
by a shared global rate limiter so the VPN egress stays polite;
|
||||
|
|
@ -51,7 +51,7 @@ Also required: the ARCGIS postcode parquet at `../property-data/arcgis_data.parq
|
|||
|
||||
## Running
|
||||
|
||||
### Docker Compose (recommended — the only way that does Zoopla)
|
||||
### Docker Compose (recommended, the only way that does Zoopla)
|
||||
|
||||
`finder/docker-compose.yml` brings up the scraper plus **FlareSolverr** (which
|
||||
solves Zoopla's Cloudflare challenge), both sharing `media_gluetun`'s netns. This
|
||||
|
|
@ -106,18 +106,18 @@ GLUETUN_PROXY="" .venv/bin/python main.py --source onthemarket --outcodes SW9 \
|
|||
| Flag | Default | Meaning |
|
||||
|------|---------|---------|
|
||||
| `--source rightmove,onthemarket` | `all` | Comma-separated portal(s): any of `rightmove`, `onthemarket`, `zoopla`, or `all`. |
|
||||
| `--outcodes SW9,E14,BR1` | — | Specific outcodes (must be Greater-London-ish). Otherwise the full London set is loaded from ARCGIS. |
|
||||
| `--limit-outcodes N` | — | Cap the number of outcodes (quick smoke). |
|
||||
| `--max-properties-per-source N` | — | Stop each source after N transformed listings. |
|
||||
| `--outcodes SW9,E14,BR1` | none | Specific outcodes (must be Greater-London-ish). Otherwise the full London set is loaded from ARCGIS. |
|
||||
| `--limit-outcodes N` | none | Cap the number of outcodes (quick smoke). |
|
||||
| `--max-properties-per-source N` | none | Stop each source after N transformed listings. |
|
||||
| `--output-dir DIR` | `data/` | Where the parquet (and `detail_cache/`) are written. |
|
||||
| `--test` | off | ~10 likely-London outcodes, ≤100 listings/source, writes to `data/test/`. |
|
||||
|
||||
> **Always pass `--output-dir /tmp/...` for testing** — the default `data/` holds
|
||||
> **Always pass `--output-dir /tmp/...` for testing**: the default `data/` holds
|
||||
> the real listings the app consumes.
|
||||
|
||||
### Stopping a run
|
||||
|
||||
`Ctrl+C` (SIGINT) — or `docker stop` (SIGTERM) — triggers a **graceful
|
||||
`Ctrl+C` (SIGINT), or `docker stop` (SIGTERM), triggers a **graceful
|
||||
shutdown**: every source stops at its next outcode boundary, in-flight delays
|
||||
and retry backoffs wake immediately, and the run still persists the detail
|
||||
caches and writes the listings collected so far before exiting (code `130`).
|
||||
|
|
@ -147,7 +147,7 @@ A **separate enrich step** (outside `finder/`) turns that into
|
|||
`online_listings_buy_enriched.parquet`, which is what the Rust backend actually
|
||||
loads (`--actual-listings-path …/online_listings_buy_enriched.parquet` in the
|
||||
top-level `docker-compose.yml`). That enrich/scheduling pipeline is **not**
|
||||
documented here — only the raw scrape is.
|
||||
documented here. Only the raw scrape is documented.
|
||||
|
||||
The top-level `docker-compose.yml` (Rust `server`, `frontend`, `pocketbase`,
|
||||
`screenshot`) is the **web app**; it is downstream of the scrape and is **not**
|
||||
|
|
|
|||
|
|
@ -26,14 +26,14 @@ RETRY_BASE_DELAY = 2.0
|
|||
DETAIL_FETCH_CONCURRENCY = int(os.environ.get("DETAIL_FETCH_CONCURRENCY", "8"))
|
||||
REQUESTS_PER_SECOND = float(os.environ.get("REQUESTS_PER_SECOND", "10"))
|
||||
GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index
|
||||
MAX_BEDROOMS = 20 # sanity cap — values above this are almost certainly parsing errors
|
||||
MAX_BEDROOMS = 20 # sanity cap: values above this are almost certainly parsing errors
|
||||
|
||||
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
|
||||
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
|
||||
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
|
||||
# Detail page (plain HTTPS GET, no Cloudflare). Its window.__PAGE_MODEL embeds
|
||||
# propertyData.address.{outcode,incode}, which together form the property's TRUE
|
||||
# full postcode — the search API only exposes the outcode. {id} is the numeric
|
||||
# full postcode. The search API only exposes the outcode. {id} is the numeric
|
||||
# listing id from the search response.
|
||||
RIGHTMOVE_DETAIL_URL = "https://www.rightmove.co.uk/properties/{id}"
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ RIGHTMOVE_MAX_DETAILS_PER_OUTCODE = 4000 # max detail-page fetches per outcode
|
|||
# keep on APPROXIMATE pins (new-builds/developments) where Rightmove
|
||||
# deliberately fuzzes the coordinates. Degrades safely: when `pinType` is absent
|
||||
# from the search payload, nothing is skipped (behaviour is unchanged), so this
|
||||
# is only a speed-up to the extent the field is present — verify against a live
|
||||
# is only a speed-up to the extent the field is present. Verify against a live
|
||||
# search response before relying on the saving.
|
||||
RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS = (
|
||||
os.environ.get("RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS", "1") != "0"
|
||||
|
|
@ -94,7 +94,7 @@ GLUETUN_API_KEY = "My8AbvnKhfyFdRhpTVfoTfa5DkAMmg8K"
|
|||
GLUETUN_MAX_ROTATIONS = 0 # max egress-IP rotations per Cloudflare challenge
|
||||
|
||||
# Zoopla fetcher: "flaresolverr" (default) solves Cloudflare via the FlareSolverr
|
||||
# sidecar (docker-compose.yml) and needs no display/VNC — verified to return the
|
||||
# sidecar (docker-compose.yml) and needs no display/VNC, verified to return the
|
||||
# RSC flight stream with postcode + coordinates; "camoufox" drives a local
|
||||
# anti-fingerprint browser (needs an interactive solve on datacenter IPs).
|
||||
ZOOPLA_FETCHER = os.environ.get("ZOOPLA_FETCHER", "flaresolverr")
|
||||
|
|
@ -214,3 +214,16 @@ PROPERTY_TYPE_MAP = {
|
|||
CHANNELS = [
|
||||
{"channel": "BUY", "transactionType": "BUY", "sortType": "2"},
|
||||
]
|
||||
|
||||
# A second search pass that restricts the BUY channel to new-build developments
|
||||
# via Rightmove's `mustHave=newHome` filter, so new homes (which can rank low in
|
||||
# the default resale sort) are captured thoroughly. The API still echoes
|
||||
# `?channel=RES_BUY` in every listing URL regardless of this filter, so new
|
||||
# builds are identified by the per-listing `development` flag in
|
||||
# `transform_property`, which re-stamps the URL channel as RES_NEW.
|
||||
NEW_HOMES_CHANNEL = {
|
||||
"channel": "BUY",
|
||||
"transactionType": "BUY",
|
||||
"sortType": "2",
|
||||
"extra_params": {"mustHave": "newHome"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""FlareSolverr client — fetch Cloudflare-protected pages as rendered HTML.
|
||||
"""FlareSolverr client: fetch Cloudflare-protected pages as rendered HTML.
|
||||
|
||||
FlareSolverr (https://github.com/FlareSolverr/FlareSolverr) drives an
|
||||
undetected browser to pass Cloudflare's challenge and returns the fully
|
||||
|
|
@ -6,7 +6,7 @@ rendered HTML. It runs as a sidecar service (see docker-compose.yml) sharing
|
|||
the Gluetun VPN network namespace, so its browser egresses through the VPN.
|
||||
|
||||
Verified working against Zoopla's managed Turnstile on a datacenter VPN IP,
|
||||
provided a reused session and a generous maxTimeout (~120s) — the first
|
||||
provided a reused session and a generous maxTimeout (~120s): the first
|
||||
challenge solve is slow, subsequent requests on the warm session are fast.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ def main() -> int:
|
|||
configure_standalone_runtime()
|
||||
configure_logging()
|
||||
# Ctrl+C (and SIGTERM, e.g. `docker stop`) asks the scrapers to wind down
|
||||
# gracefully — each source stops at its next outcode boundary and the run
|
||||
# gracefully. Each source stops at its next outcode boundary and the run
|
||||
# still persists detail caches and writes the listings collected so far.
|
||||
shutdown.install_signal_handlers()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""OnTheMarket (onthemarket.com) scraper — sale properties.
|
||||
"""OnTheMarket (onthemarket.com) scraper: sale properties.
|
||||
|
||||
OnTheMarket serves a Next.js app with the full search-results payload embedded
|
||||
as JSON in a `__NEXT_DATA__` script tag. No JS execution or browser needed:
|
||||
|
|
@ -15,19 +15,19 @@ Postcodes
|
|||
---------
|
||||
The search card exposes only an *outcode*-level address (e.g. "Padfield Road,
|
||||
London, SE5") and a map pin, so the old behaviour derived the postcode from the
|
||||
nearest postcode to that pin — a guess that frequently lands on a neighbouring
|
||||
nearest postcode to that pin, a guess that frequently lands on a neighbouring
|
||||
unit (the pin can sit on the wrong side of a street boundary).
|
||||
|
||||
Each *detail* page (`/details/{id}/`) is a plain HTTPS GET whose `__NEXT_DATA__`
|
||||
embeds the property's analytics dataLayer at
|
||||
`props.initialReduxState.metadata.dataLayer`, which carries the property's own
|
||||
`postcode` (full unit postcode, e.g. "SE5 9AA") keyed to this listing by
|
||||
`property-id`. Crucially this is NOT the agent's office postcode — that lives
|
||||
`property-id`. Crucially this is NOT the agent's office postcode. That lives
|
||||
separately at `…property.agent.postcode` ("SE5 8RS" for the same listing) and
|
||||
is the classic trap when blindly scanning the page for a postcode. We read the
|
||||
dataLayer postcode, verify `property-id` matches the listing, and accept it only
|
||||
when its outcode agrees with the coordinate-nearest postcode (via
|
||||
``resolve_listing_postcode``) — exactly the trust rule the other scrapers use.
|
||||
``resolve_listing_postcode``), exactly the trust rule the other scrapers use.
|
||||
Measured over a sample of real listings this yields a trustworthy, usually
|
||||
exact-unit postcode for ~11/12 listings; the rest safely fall back to the
|
||||
coordinate-nearest postcode.
|
||||
|
|
@ -68,8 +68,8 @@ from transform import (
|
|||
log = logging.getLogger("rightmove")
|
||||
|
||||
# Detail-page postcode recovery (see module docstring). When enabled, each
|
||||
# listing's detail page is fetched so its analytics dataLayer postcode — the
|
||||
# property's own full unit postcode — can replace the coordinate-nearest guess.
|
||||
# listing's detail page is fetched so its analytics dataLayer postcode (the
|
||||
# property's own full unit postcode) can replace the coordinate-nearest guess.
|
||||
# Bounded per outcode so a large outcode can't balloon into unbounded extra
|
||||
# HTTPS GETs. Kept at parity with the Rightmove/Zoopla detail caps (400) so a
|
||||
# typical outcode's listings all get their real postcode rather than a
|
||||
|
|
@ -145,7 +145,7 @@ def _fetch_page_json(client: httpx.Client, outcode: str, page_num: int) -> dict
|
|||
|
||||
if 300 <= resp.status_code < 400:
|
||||
log.debug(
|
||||
"OnTheMarket %s page %d redirected (%d) — end of results",
|
||||
"OnTheMarket %s page %d redirected (%d): end of results",
|
||||
outcode, page_num, resp.status_code,
|
||||
)
|
||||
return None
|
||||
|
|
@ -189,7 +189,7 @@ def parse_detail_postcode(html: str, listing_id: str | None = None) -> str | Non
|
|||
``props.initialReduxState.metadata.dataLayer.postcode`` and is the
|
||||
property's own unit postcode (e.g. "SE5 9AA"). It is deliberately NOT the
|
||||
agent's office postcode, which sits separately at
|
||||
``…property.agent.postcode`` — the trap when scanning a detail page for "a"
|
||||
``…property.agent.postcode``, the trap when scanning a detail page for "a"
|
||||
postcode. When ``listing_id`` is given, the dataLayer's ``property-id`` must
|
||||
match it, guaranteeing we read this listing's postcode and not a stray one.
|
||||
|
||||
|
|
@ -235,7 +235,7 @@ def _fetch_detail_postcode(
|
|||
|
||||
Results (including failures) are cached by listing id so a listing that
|
||||
reappears across overlapping outcode searches is fetched at most once. Plain
|
||||
HTTPS GET — OnTheMarket detail pages have no Cloudflare challenge. Network /
|
||||
HTTPS GET: OnTheMarket detail pages have no Cloudflare challenge. Network /
|
||||
parse errors degrade gracefully to None so the caller falls back to the
|
||||
coordinate-nearest postcode. Safe to call concurrently: distinct listing ids
|
||||
write distinct cache keys, and the shared RATE_LIMITER spaces the GETs.
|
||||
|
|
@ -452,7 +452,7 @@ def _prime_detail_postcodes(
|
|||
) -> None:
|
||||
"""Fill ``_detail_postcode_cache`` for the listings that need a detail page.
|
||||
|
||||
Picks the fresh (uncached) listings — up to ``detail_cap`` per outcode — then
|
||||
Picks the fresh (uncached) listings, up to ``detail_cap`` per outcode, then
|
||||
fetches their detail pages CONCURRENTLY, bounded by
|
||||
``DETAIL_FETCH_CONCURRENCY`` (the shared RATE_LIMITER keeps the combined
|
||||
request rate polite). Cached listings cost neither a slot nor a GET. The
|
||||
|
|
|
|||
|
|
@ -105,17 +105,12 @@ export default function FeatureBrowser({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('filters.searchFeatures')}
|
||||
/>
|
||||
{!search && (
|
||||
<p className="mt-2 px-1 text-xs leading-relaxed text-warm-500 dark:text-warm-400">
|
||||
{t('filters.chooseFilters')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{mergedGrouped.map((group) => {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,26 @@ export function ActiveFiltersPanel({
|
|||
{t('filters.clearAll')}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-video-hide="ai-cta"
|
||||
title={t('filters.findingPerfectPostcode')}
|
||||
aria-label={t('filters.findingPerfectPostcode')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShowPhilosophy();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
onShowPhilosophy();
|
||||
}
|
||||
}}
|
||||
className="flex text-warm-500 hover:text-teal-600 dark:text-warm-300 dark:hover:text-teal-300"
|
||||
>
|
||||
<LightbulbIcon className="w-4 h-4" />
|
||||
</span>
|
||||
<ChevronIcon
|
||||
direction={collapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-500 dark:text-warm-300"
|
||||
|
|
@ -181,16 +201,6 @@ export function ActiveFiltersPanel({
|
|||
isLoggedIn={isLoggedIn}
|
||||
onLoginRequired={onLoginRequired}
|
||||
/>
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
<button
|
||||
onClick={onShowPhilosophy}
|
||||
data-video-hide="ai-cta"
|
||||
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
{t('filters.findingPerfectPostcode')}
|
||||
</button>
|
||||
</div>
|
||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
{t('filters.addFiltersHint')}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export function AddFilterPanel({
|
|||
{(!collapsed || !isLicensed) && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{!collapsed && (
|
||||
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto md:min-h-[12rem]">
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={allFeatures}
|
||||
|
|
@ -185,32 +185,19 @@ export function AddFilterPanel({
|
|||
</div>
|
||||
)}
|
||||
{!isLicensed && (
|
||||
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
||||
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}
|
||||
</p>
|
||||
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
||||
<div className="mt-auto shrink-0 flex flex-col items-center gap-2 px-3 pt-3 pb-2.5 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
|
||||
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
{isLoggedIn ? t('filters.oneTimeLifetime') : t('filters.registerSubPrompt')}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
|
||||
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||
className="w-full px-5 py-2 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||
>
|
||||
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
|
||||
</button>
|
||||
<svg
|
||||
viewBox="0 120 1600 230"
|
||||
className="w-full mt-4 block shrink-0"
|
||||
preserveAspectRatio="xMidYMax meet"
|
||||
>
|
||||
<path
|
||||
d="M0,350 C400,150 1200,150 1600,350 Z"
|
||||
className="fill-green-500 dark:fill-green-600"
|
||||
/>
|
||||
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
|
||||
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
|
||||
<image href="/house.png" x="735" y="110" width="130" height="120" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ set -euo pipefail
|
|||
# Uses full England OSM + 2 GTFS feeds (BODS buses, National Rail).
|
||||
# R5's TransportNetwork.fromDirectory() picks up all .osm.pbf and .zip files.
|
||||
#
|
||||
# Uses each place as origin with all postcodes as destinations — R5 does one
|
||||
# Uses each place as origin with all postcodes as destinations. R5 does one
|
||||
# routing computation per place, then reads off travel times to all postcodes.
|
||||
# For car/bicycle/walking this is symmetric (place->postcode = postcode->place).
|
||||
#
|
||||
|
|
@ -27,7 +27,7 @@ THREADS=12
|
|||
# The execution cgroup caps process memory at 48 GB (see /sys/fs/cgroup/memory.max);
|
||||
# the nominal "64 GB" host total is not all addressable to one process. 28g heap +
|
||||
# ~15g native overhead (DuckDB JNI, R5 mapdb, Kryo deserialize, RAPTOR scratch)
|
||||
# leaves ~5g cgroup headroom — empirically the safe ceiling before SIGKILL.
|
||||
# leaves ~5g cgroup headroom: empirically the safe ceiling before SIGKILL.
|
||||
# Under 32g also keeps CompressedOops on, halving R5's reference-heavy footprint.
|
||||
HEAP=28g
|
||||
NETWORK_DIR=property-data/r5-network
|
||||
|
|
@ -137,7 +137,7 @@ fi
|
|||
|
||||
if [ ! -f "$NETWORK_DIR/network.dat" ]; then
|
||||
BUILD_DIR="$NETWORK_DIR/build"
|
||||
echo "--- No cached network — copying transit data to build dir ---"
|
||||
echo "--- No cached network, copying transit data to build dir ---"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
if [ ! -f "$OSM_PBF" ]; then
|
||||
echo "Error: OSM PBF not found at $OSM_PBF"
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||
* its own DuckDB connection. Routing tasks enqueue {@link WriteJob}s onto a
|
||||
* bounded {@link ArrayBlockingQueue}; if writes lag, the queue applies backpressure
|
||||
* to routing.
|
||||
* - Within a mode, the latch counts down only after the write completes — so progress
|
||||
* - Within a mode, the latch counts down only after the write completes, so progress
|
||||
* and the inter-mode barrier reflect fully-persisted work.
|
||||
*/
|
||||
public class App {
|
||||
|
|
@ -253,8 +253,8 @@ public class App {
|
|||
// Recycle the DuckDB connection every CONN_RECYCLE_EVERY writes. Long-lived
|
||||
// in-memory DuckDB connections accumulate buffer pages / catalog state that
|
||||
// doesn't get fully released by DROP TABLE alone; close + reopen forces it.
|
||||
// Set to 10 (vs 50) after leak testing showed +60MB/origin growth at 50 —
|
||||
// tighter recycling keeps the per-connection working set bounded.
|
||||
// Set to 10 (vs 50) after leak testing showed +60MB/origin growth at 50.
|
||||
// Tighter recycling keeps the per-connection working set bounded.
|
||||
final int CONN_RECYCLE_EVERY = 10;
|
||||
DuckDBConnection conn;
|
||||
try {
|
||||
|
|
@ -576,7 +576,7 @@ public class App {
|
|||
|
||||
/**
|
||||
* Filter place indices to those near at least one England postcode.
|
||||
* Uses a 0.1° grid (~11km cells) built from postcode locations — a place is kept
|
||||
* Uses a 0.1° grid (~11km cells) built from postcode locations: a place is kept
|
||||
* if its grid cell or any adjacent cell contains an England postcode.
|
||||
*/
|
||||
private static Set<Integer> filterEnglandPlaces(
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ public class Parquet {
|
|||
stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD, COMPRESSION_LEVEL 1)");
|
||||
// Drop the populated table NOW so DuckDB releases its in-memory storage
|
||||
// for the next write. Without this, the previous origin's rows linger
|
||||
// until the next call's DROP IF EXISTS — accumulating across writers.
|
||||
// until the next call's DROP IF EXISTS, accumulating across writers.
|
||||
stmt.execute("DROP TABLE t");
|
||||
}
|
||||
Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
|
|
|
|||
|
|
@ -114,13 +114,13 @@ public class Router {
|
|||
|
||||
/**
|
||||
* Load or build the transport network with Kryo caching.
|
||||
* The returned network is read-only after buildDistanceTables — safe for concurrent use.
|
||||
* The returned network is read-only after buildDistanceTables, safe for concurrent use.
|
||||
*
|
||||
* The evictable LinkageCache is left small (32 entries) because non-transit modes
|
||||
* create one huge per-origin LinkedPointSet each (~1M dests for car @ 150km radius).
|
||||
* Caching 1024 such entries OOMs the heap. Transit tile linkages instead go into
|
||||
* the unevictable {@code linkageMap} via {@link #preloadTransitTileLinkages} after
|
||||
* tiles are built — that map has no count limit and is checked first on lookup.
|
||||
* tiles are built. That map has no count limit and is checked first on lookup.
|
||||
*/
|
||||
static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception {
|
||||
// Must be set BEFORE the TransportNetwork is deserialized, since its LinkageCache
|
||||
|
|
@ -240,7 +240,7 @@ public class Router {
|
|||
int n = lats.length;
|
||||
int[] sorted = sortIndicesByLat(lats);
|
||||
|
||||
// Global lon span sets gridWidth — all tiles share the same horizontal extent
|
||||
// Global lon span sets gridWidth: all tiles share the same horizontal extent
|
||||
// bound, so each tile is a horizontal band of postcodes.
|
||||
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
|
||||
for (double lon : lons) {
|
||||
|
|
@ -390,7 +390,7 @@ public class Router {
|
|||
|
||||
/**
|
||||
* Filter destination indices to those within a bounding box of maxRadiusKm from origin.
|
||||
* Uses degree-based approximation — slightly overestimates at corners, which is fine.
|
||||
* Uses degree-based approximation. Slightly overestimates at corners, which is fine.
|
||||
* Backed by STRtree: O(log n + k) per query instead of O(n) scan.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
|
|
@ -421,7 +421,7 @@ public class Router {
|
|||
double[] lats, double[] lons, int maxDestsPerChunk) {
|
||||
int n = lats.length;
|
||||
|
||||
// Sort indices by latitude for geographic chunking — primitive long sort to
|
||||
// Sort indices by latitude for geographic chunking: primitive long sort to
|
||||
// avoid Integer[] autoboxing per origin (millions of Integer allocs at scale).
|
||||
// Pack: high 32 bits = lat as sortable int, low 32 bits = original index.
|
||||
int[] sorted = sortIndicesByLat(lats);
|
||||
|
|
|
|||
|
|
@ -33,13 +33,13 @@ pub const AI_FILTERS_WEEKLY_TOKEN_LIMIT: u64 = 10_000_000;
|
|||
pub const SERVICE_CALL_TIMEOUT: u64 = 120;
|
||||
|
||||
/// Anonymous (logged-out) users may apply at most this many filters at a time. This
|
||||
/// is the only gate on the demo: there is no region lock — they get the full
|
||||
/// is the only gate on the demo: there is no region lock: they get the full
|
||||
/// dashboard everywhere, just capped on simultaneous filters. Enforced both
|
||||
/// client-side (UX) and server-side (defense-in-depth); must match the frontend
|
||||
/// constant `DEMO_MAX_FILTERS`.
|
||||
pub const DEMO_MAX_FILTERS: usize = 3;
|
||||
/// Registered but non-paying accounts get a higher filter allowance than anonymous
|
||||
/// visitors — the incentive to create a free account. Paying/licensed users are
|
||||
/// visitors, the incentive to create a free account. Paying/licensed users are
|
||||
/// uncapped. Must match the frontend constant `REGISTERED_MAX_FILTERS`.
|
||||
pub const REGISTERED_MAX_FILTERS: usize = 5;
|
||||
/// Sliding-window rate limit for unlicensed traffic to `/api/` endpoints, keyed by
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ pub struct FeatureGroup {
|
|||
|
||||
/// Expand each crime type into its two filterable features: a 7-year and a
|
||||
/// 2-year window. Each is the average number of recorded incidents per year (the
|
||||
/// raw, absolute count — no per-area or per-capita normalisation). The names must
|
||||
/// raw, absolute count: no per-area or per-capita normalisation). The names must
|
||||
/// match the `"{type} (/yr, 7y|2y)"` columns written by `crime_spatial`. The
|
||||
/// per-incident records are NOT a feature (they are a display-only side table the
|
||||
/// server loads directly), so they never appear here and are not filterable.
|
||||
|
|
@ -81,12 +81,12 @@ macro_rules! crime_features {
|
|||
name: concat!($base, " (/yr, 7y)"),
|
||||
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
|
||||
step: 0.1,
|
||||
description: concat!($blurb, " — average recorded incidents per year (last 7 years)"),
|
||||
description: concat!($blurb, ": average recorded incidents per year (last 7 years)"),
|
||||
detail: concat!(
|
||||
$blurb,
|
||||
", as the average number of recorded incidents per year, over the last \
|
||||
7 years. Counted from police.uk street-level crime points (anonymised, \
|
||||
snapped to nearby map points) that fall near the postcode boundary — \
|
||||
snapped to nearby map points) that fall near the postcode boundary: \
|
||||
the raw, absolute count, with no per-area or per-capita adjustment. \
|
||||
Computed over the months the local police force actually published; \
|
||||
known force gaps (e.g. Greater Manchester since mid-2019) are excluded, \
|
||||
|
|
@ -102,11 +102,11 @@ macro_rules! crime_features {
|
|||
name: concat!($base, " (/yr, 2y)"),
|
||||
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
|
||||
step: 0.1,
|
||||
description: concat!($blurb, " — average recorded incidents per year (last 2 years)"),
|
||||
description: concat!($blurb, ": average recorded incidents per year (last 2 years)"),
|
||||
detail: concat!(
|
||||
$blurb,
|
||||
", as the average number of recorded incidents per year, over the last \
|
||||
2 years — a more recent but noisier window than the 7-year figure. From \
|
||||
2 years: a more recent but noisier window than the 7-year figure. From \
|
||||
police.uk street-level crime points near the postcode boundary (the raw, \
|
||||
absolute count), over the months the local force published (gaps \
|
||||
excluded, not zeroed)."
|
||||
|
|
@ -248,7 +248,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Maximum transport noise level near the postcode in decibels (Lden)",
|
||||
detail: "Loudest of road, rail, or airport noise in decibels (Lden, a 24-hour day-evening-night weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Covers England only; rail noise dominates the value at ~120k postcodes and airport noise at ~4k. Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Blank means no mapped data in the source (Wales, Scotland and areas away from major roads/railways/airports all return blank) — not necessarily quiet. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
|
||||
detail: "Loudest of road, rail, or airport noise in decibels (Lden, a 24-hour day-evening-night weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Covers England only; rail noise dominates the value at ~120k postcodes and airport noise at ~4k. Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Blank means no mapped data in the source (Wales, Scotland and areas away from major roads/railways/airports all return blank). It does not necessarily mean the area is quiet. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
|
||||
source: "noise",
|
||||
prefix: "",
|
||||
suffix: " dB",
|
||||
|
|
@ -528,11 +528,11 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
features: crime_features![
|
||||
(
|
||||
"Serious crime",
|
||||
"Serious crime — violence, robbery, burglary and weapons possession — near the postcode"
|
||||
"Serious crime (violence, robbery, burglary and weapons possession) near the postcode"
|
||||
),
|
||||
(
|
||||
"Minor crime",
|
||||
"Lower-severity crime — anti-social behaviour, theft, criminal damage, drugs and public order — near the postcode"
|
||||
"Lower-severity crime (anti-social behaviour, theft, criminal damage, drugs and public order) near the postcode"
|
||||
),
|
||||
(
|
||||
"Violence and sexual offences",
|
||||
|
|
@ -638,7 +638,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||
step: 1.0,
|
||||
description: "Share of residents (16+) whose highest qualification is A-levels (Level 3)",
|
||||
detail: "From the 2021 Census (TS067). Highest qualification is A-levels, AS-levels, T-levels, an advanced apprenticeship, or equivalent — typically studied after 16 and before a degree. The ONS calls this 'Level 3'.",
|
||||
detail: "From the 2021 Census (TS067). Highest qualification is A-levels, AS-levels, T-levels, an advanced apprenticeship, or equivalent, typically studied after 16 and before a degree. The ONS calls this 'Level 3'.",
|
||||
source: "census-2021",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
|
|
@ -650,7 +650,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||
step: 1.0,
|
||||
description: "Share of residents (16+) with a degree-level or higher qualification",
|
||||
detail: "From the 2021 Census (TS067). Highest qualification is degree level or above — a Bachelor's, Master's or PhD, foundation degree, HNC/HND, NVQ 4-5, or higher professional qualification. The census does not separate undergraduate from postgraduate degrees. The ONS calls this 'Level 4 or above'.",
|
||||
detail: "From the 2021 Census (TS067). Highest qualification is degree level or above: a Bachelor's, Master's or PhD, foundation degree, HNC/HND, NVQ 4-5, or higher professional qualification. The census does not separate undergraduate from postgraduate degrees. The ONS calls this 'Level 4 or above'.",
|
||||
source: "census-2021",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
|
|
@ -662,7 +662,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||
step: 1.0,
|
||||
description: "Share of residents (16+) with other qualifications, including vocational or overseas ones",
|
||||
detail: "From the 2021 Census (TS067). Highest qualification is classed as 'other' — vocational or professional qualifications not mapped to a UK level, and qualifications gained outside the UK.",
|
||||
detail: "From the 2021 Census (TS067). Highest qualification is classed as 'other': vocational or professional qualifications not mapped to a UK level, and qualifications gained outside the UK.",
|
||||
source: "census-2021",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
|
|
@ -673,8 +673,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
// shares sum to ~100% per neighbourhood (LSOA) and render as a
|
||||
// stacked composition (see STACKED_GROUPS["Neighbours"] in the
|
||||
// frontend), like the ethnicity, qualifications and vote-share bars.
|
||||
// Unlike those — each folded into a single dropdown filter that
|
||||
// selects one band — the three tenure shares are offered as
|
||||
// Unlike those (each folded into a single dropdown filter that
|
||||
// selects one band), the three tenure shares are offered as
|
||||
// individual filters, so users can target e.g. owner-occupier-heavy
|
||||
// areas.
|
||||
Feature::Numeric(FeatureConfig {
|
||||
|
|
@ -713,6 +713,37 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
// EPC-derived council-housing footprint for the neighbourhood (LSOA).
|
||||
// Aggregated in the pipeline from the per-property "Former council
|
||||
// house" flag and the latest certificate tenure. The denominator is
|
||||
// ALL dwellings in the LSOA (homes with no EPC count as not council),
|
||||
// so these are EPC-coverage-limited lower bounds, NOT a Census
|
||||
// household share. These two plus the Census "% Social rent" back the
|
||||
// frontend "Council housing" filter's Current / Ex / Both pill toggle.
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Council housing",
|
||||
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||
step: 1.0,
|
||||
description: "Share of nearby homes ever recorded as council or social housing",
|
||||
detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes whose Energy Performance Certificate history shows the property was council or social housing at some point, whether it is still social housing today or has since been sold (for example under Right to Buy). Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.",
|
||||
source: "epc",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Ex-council",
|
||||
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||
step: 1.0,
|
||||
description: "Share of nearby homes that were council housing but are no longer",
|
||||
detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes once recorded as council or social housing whose most recent Energy Performance Certificate shows a different tenure, typically homes sold under Right to Buy. Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.",
|
||||
source: "epc",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% White",
|
||||
bounds: Bounds::Fixed {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ pub fn build_system_prompt(
|
|||
- Leave out any filter the user did not mention. Empty arrays are fine.\n\
|
||||
- Each numeric filter sets ONE bound only: \"min\" (at least this value) \
|
||||
or \"max\" (at most this value). Never set two filters on the same feature.\n\
|
||||
- Use EXACT feature names from the list — spelling, capitalisation, and punctuation must match.\n\
|
||||
- Use EXACT feature names from the list: spelling, capitalisation, and punctuation must match.\n\
|
||||
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
|
||||
- \"low crime\" / \"safe\" = low values on the Serious crime (/yr, 7y) and Minor crime (/yr, 7y) \
|
||||
features (average recorded incidents per year near the postcode, last 7 years). Prefer these aggregates for broad \
|
||||
|
|
@ -39,7 +39,9 @@ pub fn build_system_prompt(
|
|||
% Other parties, or Voter turnout (%) when the user asks for political character.\n\
|
||||
- Housing tenure is a normal filter in the Neighbours group. \"lots of homeowners\" / \
|
||||
\"owner-occupied area\" = high % Owner occupied; \"rental area\" / \"lots of renters\" = \
|
||||
high % Private rent; \"social housing\" / \"council housing\" = high % Social rent.\n\
|
||||
high % Private rent; \"social housing\" / \"council housing\" = high % Social rent. \
|
||||
\"ex-council\" / \"former council\" / \"right to buy\" = high % Ex-council; \"council \
|
||||
estate\" character whether current or former = high % Council housing.\n\
|
||||
- Education level is a normal filter in the Neighbours group. \"well-educated\" / \
|
||||
\"graduate\" / \"professional\" / \"university-educated area\" = high % Degree or higher; \
|
||||
\"few qualifications\" = high % No qualifications.\n\
|
||||
|
|
@ -88,7 +90,7 @@ pub fn build_system_prompt(
|
|||
\n\
|
||||
When the user mentions a specific place, you MUST call the search_destinations \
|
||||
tool to find the exact slug. Use the name and slug from the search results.\n\
|
||||
If search_destinations returns an empty array, the destination is not available — \
|
||||
If search_destinations returns an empty array, the destination is not available; \
|
||||
mention it in \"notes\" (e.g. \"No travel data for: Gatwick Airport\") and do NOT \
|
||||
include a travel_time_filter for it.\n\
|
||||
\n\
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ pub(super) async fn fetch_ai_usage(
|
|||
}
|
||||
|
||||
/// Update the user's AI token usage in PocketBase.
|
||||
/// Best-effort — logs warnings on failure but does not propagate errors.
|
||||
/// Best-effort: logs warnings on failure but does not propagate errors.
|
||||
async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week: u64) {
|
||||
let token = match get_superuser_token(state).await {
|
||||
Ok(tk) => tk,
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ pub fn compute_feature_stats(
|
|||
if value.is_finite() {
|
||||
// Reject negatives, NaN-via-large-cast, and any out-of-range
|
||||
// index. A schema/data mismatch is a critical data-integrity
|
||||
// bug — skip the row, count it, and surface as error so
|
||||
// bug: skip the row, count it, and surface as error so
|
||||
// monitoring catches it.
|
||||
let len = value_counts.len();
|
||||
let idx_ok = value >= 0.0 && (value as usize) < len;
|
||||
|
|
@ -154,7 +154,7 @@ pub fn compute_feature_stats(
|
|||
feature = feature_names[fi].as_str(),
|
||||
value,
|
||||
max = len,
|
||||
"Enum index out of bounds — data/schema mismatch"
|
||||
"Enum index out of bounds: data/schema mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -296,7 +296,7 @@ pub fn compute_enum_feature_counts(
|
|||
/// Denominators are COVERAGE-AWARE: police.uk has multi-year publication gaps
|
||||
/// for whole forces (e.g. Greater Manchester from 2019-07), and the pipeline
|
||||
/// emits a `covered_years` calendar per postcode. A postcode only counts toward
|
||||
/// a year's denominator if its force published that year — and only then does
|
||||
/// a year's denominator if its force published that year, and only then does
|
||||
/// its missing bar mean a genuine zero. Years no selected postcode covers are
|
||||
/// omitted entirely (charted as gaps, not zeros). Postcodes without coverage
|
||||
/// info (legacy parquet without the column) count toward every year, restoring
|
||||
|
|
@ -337,7 +337,7 @@ pub fn compute_crime_by_year(
|
|||
|
||||
// A postcode with a row but no series for a given type had no recorded
|
||||
// incidents of that type: it contributes 0 to the sums, and its covered
|
||||
// years still count in the denominator — a genuine zero. Uncovered
|
||||
// years still count in the denominator: a genuine zero. Uncovered
|
||||
// years are excluded via the denominators instead.
|
||||
if let Some(series_list) = crime_by_year.series_by_postcode.get(postcode) {
|
||||
for series in series_list {
|
||||
|
|
@ -575,7 +575,7 @@ mod tests {
|
|||
assert_eq!(all.get("Yes"), Some(&2));
|
||||
assert_eq!(all.get("No"), Some(&1));
|
||||
|
||||
// A filter-matching subset would yield a different tally — confirming
|
||||
// A filter-matching subset would yield a different tally, confirming
|
||||
// the count is driven purely by the rows passed in (so callers can pass
|
||||
// the full area to make it filter-independent).
|
||||
let subset = compute_enum_feature_counts(&[0, 3], &data, 0).unwrap();
|
||||
|
|
@ -621,7 +621,7 @@ mod tests {
|
|||
|
||||
let robbery = out.iter().find(|c| c.name == "Robbery (/yr, 7y)").unwrap();
|
||||
assert_eq!(robbery.national, Some(6.0));
|
||||
// The outcode value was NaN — dropped to None; the sector value is finite.
|
||||
// The outcode value was NaN, dropped to None; the sector value is finite.
|
||||
assert_eq!(robbery.outcode, None);
|
||||
assert_eq!(robbery.sector, Some(7.0));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ pub async fn get_travel_destinations(
|
|||
// Sort: type rank asc, population desc, name length asc
|
||||
matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4)));
|
||||
|
||||
// Deduplicate by slug — multiple places can share a name/slug
|
||||
// Deduplicate by slug: multiple places can share a name/slug
|
||||
// (e.g. "Richmond" as city + suburb), keep the best-ranked one
|
||||
let mut seen_slugs = FxHashSet::default();
|
||||
matches.retain(|(_, slug, ..)| seen_slugs.insert(slug.clone()));
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ impl GridIndex {
|
|||
}
|
||||
|
||||
/// Count the number of row indices within the given bounds without allocating.
|
||||
/// O(grid cells in bounds) — much cheaper than query() for threshold decisions.
|
||||
/// O(grid cells in bounds): much cheaper than query() for threshold decisions.
|
||||
pub fn count_in_bounds(&self, south: f64, west: f64, north: f64, east: f64) -> usize {
|
||||
let Some((row_min, row_max, col_min, col_max)) =
|
||||
self.clamp_bounds(south, west, north, east)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
# language variants there; this script renders every emitted slug.
|
||||
#
|
||||
# Two targets:
|
||||
# local (default) — assumes the docker-compose stack on host.docker.internal,
|
||||
# local (default): assumes the docker-compose stack on host.docker.internal,
|
||||
# bootstraps a recorder admin user automatically.
|
||||
# prod — points at https://perfect-postcode.co.uk and skips the
|
||||
# prod: points at https://perfect-postcode.co.uk and skips the
|
||||
# bootstrap step; you supply real account credentials.
|
||||
#
|
||||
# Usage:
|
||||
|
|
@ -27,10 +27,10 @@
|
|||
# APP_URL=http://localhost:3001 ./render.sh # override frontend URL
|
||||
#
|
||||
# Cred env vars (read for both targets, but prod has no fallback defaults):
|
||||
# LOGIN_EMAIL, LOGIN_PASSWORD — the dashboard account to record as
|
||||
# LOGIN_EMAIL, LOGIN_PASSWORD: the dashboard account to record as
|
||||
# (same email/password you'd type into
|
||||
# the login modal)
|
||||
# PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD — PocketBase superuser, only used by
|
||||
# PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD: PocketBase superuser, only used by
|
||||
# --target local to bootstrap the
|
||||
# recorder user; ignored on --prod
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ else
|
|||
fi
|
||||
export PB_BOOTSTRAP_ADMIN
|
||||
export LOGIN_EMAIL LOGIN_PASSWORD
|
||||
# Per-target storage state — switching targets must not reuse a stale token.
|
||||
# Per-target storage state: switching targets must not reuse a stale token.
|
||||
# config.ts reads AUTH_STATE_FILE for AUTH_STATE_PATH.
|
||||
export AUTH_STATE_FILE="${AUTH_STATE_FILE:-auth.${TARGET}.json}"
|
||||
AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if cache older than this
|
||||
|
|
@ -306,7 +306,7 @@ if [ "$DO_AUDIO" = "1" ]; then
|
|||
export UV_HTTP_TIMEOUT="${UV_HTTP_TIMEOUT:-600}"
|
||||
# Pull in the flash-attn prebuilt wheel (defined as the `gpu` extra) when
|
||||
# the host actually has a GPU. The wheel is bound to torch 2.6 + cu12 +
|
||||
# cp312 — see tts/pyproject.toml.
|
||||
# cp312. See tts/pyproject.toml.
|
||||
uv_sync_extras=()
|
||||
if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
|
||||
uv_sync_extras+=(--extra gpu)
|
||||
|
|
@ -331,7 +331,7 @@ if [ "$DO_AUDIO" = "1" ]; then
|
|||
# Voice consistency: every ad in this set declares the same AD_VOICE
|
||||
# (instruct/seed/temperature/topP/referenceText). Even with seed-locked
|
||||
# VoiceDesign, independent invocations across processes can produce
|
||||
# mildly different reference waveforms — different enough that a
|
||||
# mildly different reference waveforms, different enough that a
|
||||
# listener notices the timbre shift across ads. To avoid that, we
|
||||
# mint the reference WAV ONCE (from the first storyboard) and reuse
|
||||
# it across the rest of the storyboards by copying _reference.wav +
|
||||
|
|
@ -342,7 +342,7 @@ if [ "$DO_AUDIO" = "1" ]; then
|
|||
# We copy ONLY the reference, never the cue wavs or index.json. Copying
|
||||
# the whole audio dir (as an earlier version did) overwrote each later
|
||||
# storyboard's cached index.json with the FIRST storyboard's, which
|
||||
# forced a full re-synth on every run — and in multi-voice sets (the
|
||||
# forced a full re-synth on every run, and in multi-voice sets (the
|
||||
# localized homepage demos: en/de/zh/hi) it clobbered correct localized
|
||||
# audio. With a reference-only copy: same-voice sets reuse the reference
|
||||
# (meta matches); different-voice sets re-mint their own (meta mismatch),
|
||||
|
|
@ -364,7 +364,7 @@ if [ "$DO_AUDIO" = "1" ]; then
|
|||
if [ -z "$shared_ref_wav" ] && [ -f "output/$sb/audio/_reference.wav" ]; then
|
||||
shared_ref_wav="output/$sb/audio/_reference.wav"
|
||||
shared_ref_meta="output/$sb/audio/_reference.meta.json"
|
||||
say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set"
|
||||
say "Locked voice reference to $shared_ref_wav; reusing for the rest of the set"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
|
@ -422,7 +422,7 @@ for sb in "${STORYBOARDS[@]}"; do
|
|||
|
||||
if [ "$DO_ENCODE" = "1" ] && [ "$DO_AUDIO" = "1" ]; then
|
||||
if [ ! -s "output/$sb/narration.json" ]; then
|
||||
fail "[$sb] narration.json missing — recorder did not log cues"
|
||||
fail "[$sb] narration.json missing: recorder did not log cues"
|
||||
fi
|
||||
say "[$sb] Muxing narration into output/$sb/recording.mp4"
|
||||
uv run --project tts python tts/mux.py --storyboard "$sb" --replace \
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { ensureRecorderAdminUser } from './pb-admin.js';
|
|||
* 1. Programmatic (preferred for CI / non-interactive runs): set
|
||||
* LOGIN_EMAIL and LOGIN_PASSWORD env vars (the same email/password you'd
|
||||
* type into the dashboard's login modal). We drive the actual login form
|
||||
* in a headless browser — same path a real user takes, no knowledge of
|
||||
* in a headless browser: the same path a real user takes, no knowledge of
|
||||
* the PocketBase REST endpoint required.
|
||||
*
|
||||
* 2. Interactive: no env vars, we open a headed browser, you log in by hand,
|
||||
|
|
@ -49,7 +49,7 @@ async function programmatic() {
|
|||
{ timeout: 15_000 }
|
||||
);
|
||||
|
||||
// Skip the react-joyride product tour — its spotlight overlay intercepts
|
||||
// Skip the react-joyride product tour: its spotlight overlay intercepts
|
||||
// pointer events and breaks the recording.
|
||||
await page.evaluate(() => { localStorage.setItem('tutorial_completed', '1'); });
|
||||
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ async function suppressDevServerNoise(context: BrowserContext) {
|
|||
[class*="webpack-error"],
|
||||
/* Recording-only: hide dashboard chrome that reads as noise/loading
|
||||
on camera (the "Finding the Perfect Postcode" AI-status CTA). The
|
||||
data attribute is inert in production — only this injected rule
|
||||
data attribute is inert in production: only this injected rule
|
||||
targets it, and only during a recording. */
|
||||
[data-video-hide] {
|
||||
display: none !important;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ function requiredEnv(name: string): string {
|
|||
}
|
||||
|
||||
// Environment-only knobs. Per-storyboard tuning (aspect, fps, bitrate,
|
||||
// voice, prompts, brand…) lives on the Storyboard object itself — see
|
||||
// voice, prompts, brand…) lives on the Storyboard object itself. See
|
||||
// src/storyboard.ts.
|
||||
|
||||
export const APP_URL = requiredEnv('APP_URL');
|
||||
|
|
@ -20,5 +20,5 @@ export const OUTPUT_DIR = 'output';
|
|||
|
||||
// Frames of head-room kept in front of sceneStart when trimming. Shared by
|
||||
// the video trim and the narration manifest so cue offsets line up with the
|
||||
// trimmed timeline. Not tuned per storyboard — same lead-in for any cut.
|
||||
// trimmed timeline. Not tuned per storyboard: same lead-in for any cut.
|
||||
export const LEAD_IN_S = 0.12;
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export class DashboardRecorder {
|
|||
/**
|
||||
* Best-effort wait for tracked API traffic to go quiet (250ms of no
|
||||
* pending requests and no loading indicator). Unlike waitForStable this
|
||||
* never throws — it simply returns at the deadline. Used before computing
|
||||
* never throws: it simply returns at the deadline. Used before computing
|
||||
* hexagon click targets so the projection runs against the response for
|
||||
* the CURRENT viewport rather than one captured mid-animation.
|
||||
*/
|
||||
|
|
@ -133,7 +133,7 @@ export class DashboardRecorder {
|
|||
// Empty responses don't replace the snapshot (parse* skips them), so a
|
||||
// snapshot whose bounds differ from the latest REQUEST means the current
|
||||
// view has zero matching features and we'd be projecting stale data.
|
||||
// Surface that loudly — it almost always means the storyboard's filters
|
||||
// Surface that loudly: it almost always means the storyboard's filters
|
||||
// emptied the area it zoomed into.
|
||||
const snapshotBounds = this.lastPostcodes?.bounds ?? this.lastHexagons?.bounds;
|
||||
if (snapshotBounds && this.lastRequestedMapBounds) {
|
||||
|
|
@ -141,7 +141,7 @@ export class DashboardRecorder {
|
|||
if (snapKey !== this.lastRequestedMapBounds) {
|
||||
console.log(
|
||||
`[dashboard] WARNING: map snapshot is stale (snapshot bounds ${snapKey} ` +
|
||||
`vs latest request ${this.lastRequestedMapBounds}) — the current view ` +
|
||||
`vs latest request ${this.lastRequestedMapBounds}). The current view ` +
|
||||
`likely has no matching features; clicks may land on empty map.`
|
||||
);
|
||||
}
|
||||
|
|
@ -221,7 +221,7 @@ export class DashboardRecorder {
|
|||
}
|
||||
|
||||
/**
|
||||
* The pixel rect inside `mapBox` that's safe to click — i.e. not under
|
||||
* The pixel rect inside `mapBox` that's safe to click, i.e. not under
|
||||
* the dashboard's left filters pane, right details pane, or (on mobile)
|
||||
* the floating MobileBottomSheet. We detect the sheet via the only
|
||||
* `section.rounded-t-2xl` in the DOM and treat its top as a hard
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import type { AdScene, AdScenePanel } from './script.js';
|
|||
* native cursor is hidden so what the viewer sees is entirely our element.
|
||||
*
|
||||
* Design choice: the cursor listens to mousemove rather than being driven from
|
||||
* the Node side. That keeps a single source of truth — Playwright's real mouse
|
||||
* — and the visual is pure CSS, animated by the browser's compositor.
|
||||
* the Node side. That keeps a single source of truth (Playwright's real mouse),
|
||||
* and the visual is pure CSS, animated by the browser's compositor.
|
||||
*/
|
||||
export async function installCursor(
|
||||
page: Page,
|
||||
|
|
@ -235,7 +235,7 @@ export async function installCursor(
|
|||
letter-spacing: -0.01em;
|
||||
color: #5eead4;
|
||||
}
|
||||
/* Tighter outro for vertical 9:16 — the brand/url stack must fit
|
||||
/* Tighter outro for vertical 9:16. The brand/url stack must fit
|
||||
comfortably inside the platform-safe centre column. */
|
||||
body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; }
|
||||
body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; }
|
||||
|
|
@ -270,7 +270,7 @@ export async function installCursor(
|
|||
-webkit-backdrop-filter: blur(3px) saturate(0.92);
|
||||
}
|
||||
/*
|
||||
* Transparent mode: no scrim, no blur — the product stays fully
|
||||
* Transparent mode: no scrim, no blur, so the product stays fully
|
||||
* visible behind a floating kicker + title. Used by mid-cue hook
|
||||
* stings ("Postcode polygraph") that should not occlude the demo.
|
||||
*/
|
||||
|
|
@ -475,7 +475,7 @@ export async function installCursor(
|
|||
/*
|
||||
* Mobile (9x16) renders the dashboard at CSS 540x960 with captureScale 2.
|
||||
* That means the AdScene CSS is sizing against a 540px-wide viewport, so
|
||||
* a 64px title is ~12% of viewport width per line — still big and bold,
|
||||
* a 64px title is ~12% of viewport width per line, still big and bold,
|
||||
* but no longer overflows the available space the way 82px did on the
|
||||
* old 1080-wide ad config. Captions also re-anchor to the upper third
|
||||
* via body.__demo-aspect-vertical.
|
||||
|
|
@ -601,7 +601,7 @@ export async function clearVignette(page: Page): Promise<void> {
|
|||
* Tag the document body with the aspect class the caption / overlay CSS keys
|
||||
* off. Run once during recorder setup so every cue inherits the right
|
||||
* positioning without per-call overrides. The body class is the cheapest
|
||||
* stable signal — the storyboard's `video.aspect` knows the truth and we
|
||||
* stable signal. The storyboard's `video.aspect` knows the truth and we
|
||||
* surface it once into the DOM.
|
||||
*/
|
||||
export async function setAspectClass(
|
||||
|
|
@ -741,7 +741,7 @@ export async function showAdScene(page: Page, scene: AdScene): Promise<void> {
|
|||
img.className = '__ad-image';
|
||||
// Skip crossOrigin so the request goes through as a vanilla
|
||||
// image fetch and CORS headers on the Unsplash CDN are not
|
||||
// required. We never read pixels back out — display only.
|
||||
// required. We never read pixels back out: display only.
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.onerror = () => img.remove();
|
||||
img.src = src;
|
||||
|
|
@ -872,7 +872,7 @@ export async function showOutro(
|
|||
* without dragging the cursor/caption/outro overlays along with it.
|
||||
*
|
||||
* Why a wrapper and not <body>: a transformed ancestor establishes a new
|
||||
* containing block for `position: fixed` descendants — meaning fixed
|
||||
* containing block for `position: fixed` descendants, meaning fixed
|
||||
* overlays inside the transform get scaled too. By wrapping ONLY #root
|
||||
* and leaving the overlays as siblings of the wrapper, the cursor stays
|
||||
* at native size while the dashboard zooms behind it.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { storyboards } from './storyboard.js';
|
|||
* Emit per-storyboard narration scripts for the synth step.
|
||||
*
|
||||
* Synth (tts/synth.py) runs BEFORE recording, so it needs the full ordered
|
||||
* narration list — text + per-cue gaps + voice config — without depending
|
||||
* narration list (text + per-cue gaps + voice config) without depending
|
||||
* on Playwright, the dashboard, or auth. Walk each storyboard's cues, write
|
||||
* a flat manifest under `output/<name>/narration-script.json`, then write
|
||||
* an index manifest at `output/storyboards.json` so render.sh knows which
|
||||
|
|
@ -18,11 +18,11 @@ import { storyboards } from './storyboard.js';
|
|||
* matches storyboard cues to measured durations by index.
|
||||
*/
|
||||
// Em/en-dashes and ellipses make Qwen3-TTS produce dramatic pauses, sighs,
|
||||
// or audible breaths — the captions still render the original (unicode-rich)
|
||||
// or audible breaths. The captions still render the original (unicode-rich)
|
||||
// text from the storyboard; only the synth input is sanitised.
|
||||
function normalizeForTts(text: string): string {
|
||||
return text
|
||||
.replace(/\s*[—–]\s*/g, ', ')
|
||||
.replace(/\s*[\u2014\u2013]\s*/g, ', ')
|
||||
.replace(/…/g, '.')
|
||||
.replace(/\.{3,}/g, '.')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
|
|
@ -39,7 +39,7 @@ function emitScript(storyboard: Storyboard): string {
|
|||
gapBeforeMs: cue.gapBeforeMs,
|
||||
}));
|
||||
|
||||
// The voice block is consumed by tts/synth.py — see _resolve_reference and
|
||||
// The voice block is consumed by tts/synth.py. See _resolve_reference and
|
||||
// the cache check there for which fields invalidate cached audio.
|
||||
const manifest = {
|
||||
storyboard: storyboard.name,
|
||||
|
|
@ -61,7 +61,7 @@ function emitScript(storyboard: Storyboard): string {
|
|||
|
||||
/**
|
||||
* Validate every stubbed/initial filter name and travel-destination slug
|
||||
* against the LIVE API. Wrong names don't error in the app — they silently
|
||||
* against the LIVE API. Wrong names don't error in the app: they silently
|
||||
* no-op, the map never changes, and you only find out after a full render.
|
||||
* Fails hard on a mismatch; soft-warns if the API is unreachable (render.sh
|
||||
* has already health-checked it by the time preflight runs).
|
||||
|
|
@ -69,7 +69,7 @@ function emitScript(storyboard: Storyboard): string {
|
|||
async function validateAgainstLiveApi(): Promise<void> {
|
||||
const apiBase = process.env.API_URL ?? process.env.APP_URL;
|
||||
if (!apiBase) {
|
||||
console.warn('[preflight] no API_URL/APP_URL set — skipping live filter validation');
|
||||
console.warn('[preflight] no API_URL/APP_URL set, skipping live filter validation');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ async function validateAgainstLiveApi(): Promise<void> {
|
|||
};
|
||||
featureNames = new Set(body.groups.flatMap((g) => g.features.map((f) => f.name)));
|
||||
} catch (err) {
|
||||
console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}) — skipping validation`);
|
||||
console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}), skipping validation`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ async function main(): Promise<void> {
|
|||
|
||||
for (const sb of storyboards) emitScript(sb);
|
||||
|
||||
// Index for shell loops — each entry has every field render.sh needs to
|
||||
// Index for shell loops: each entry has every field render.sh needs to
|
||||
// address per-storyboard outputs without re-parsing the TS source.
|
||||
const index = {
|
||||
storyboards: storyboards.map((sb) => ({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { viewportFor } from './script.js';
|
|||
import { storyboards } from './storyboard.js';
|
||||
|
||||
async function main() {
|
||||
// probe is a debug utility — pin it to the first storyboard's viewport.
|
||||
// probe is a debug utility: pin it to the first storyboard's viewport.
|
||||
const viewport = viewportFor(storyboards[0].video);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ async function runCue(
|
|||
`Trim a during step, lengthen the cue text, or move work into tail.`
|
||||
);
|
||||
}
|
||||
// Time the during block as a whole — individual steps may overrun their
|
||||
// Time the during block as a whole: individual steps may overrun their
|
||||
// budgets, but what matters at the cue boundary is total wall-clock.
|
||||
const duringStart = Date.now();
|
||||
for (const step of during) {
|
||||
|
|
@ -393,7 +393,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
|
|||
case 'dragSheet': {
|
||||
const sheet = ctx.page.locator('section[class*="rounded-t-2xl"]').first();
|
||||
const sheetBox = await sheet.boundingBox().catch(() => null);
|
||||
if (!sheetBox) return; // desktop layout — nothing to drag
|
||||
if (!sheetBox) return; // desktop layout: nothing to drag
|
||||
const handle = ctx.page
|
||||
.locator('section[class*="rounded-t-2xl"] [class*="touch-none"]')
|
||||
.first();
|
||||
|
|
@ -427,7 +427,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
|
|||
}
|
||||
case 'openFilterGroup':
|
||||
// Click is idempotent: if the group is already expanded, the click
|
||||
// would collapse it — which we don't want. Detect via aria-expanded
|
||||
// would collapse it, which we don't want. Detect via aria-expanded
|
||||
// (Radix Accordion sets it on the trigger) and skip the click when
|
||||
// the group is already open.
|
||||
await ctx.page.evaluate((selector) => {
|
||||
|
|
@ -493,7 +493,7 @@ async function tryResolveTarget(
|
|||
|
||||
/**
|
||||
* Load synth's measured cue durations. Falls back to a worst-case estimate
|
||||
* if the manifest is missing — that path is only used for ``--no-audio``
|
||||
* if the manifest is missing: that path is only used for ``--no-audio``
|
||||
* runs, where the visual flow needs to play even without speech to time
|
||||
* against.
|
||||
*/
|
||||
|
|
@ -516,7 +516,7 @@ function loadSynthIndex(storyboard: Storyboard): SynthCue[] {
|
|||
});
|
||||
}
|
||||
console.log(
|
||||
`[runner] no ${path} found — using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.`
|
||||
`[runner] no ${path} found. Using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.`
|
||||
);
|
||||
return storyboard.cues.map((cue, cueIndex) => ({
|
||||
cueIndex,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import type { DashboardRecorder } from './dashboard.js';
|
|||
/**
|
||||
* Public scripting API for the demo video.
|
||||
*
|
||||
* The storyboard is a `Storyboard` — an ordered list of narration cues, each
|
||||
* The storyboard is a `Storyboard`: an ordered list of narration cues, each
|
||||
* carrying the activities that play alongside it. Audio is generated FIRST
|
||||
* (one batched Qwen call so the voice stays consistent across cues); the
|
||||
* runner then reads the measured per-cue durations and slots `during`
|
||||
* activities inside each cue's audio window.
|
||||
*
|
||||
* Why cue-anchored: the audio drives pacing. Re-running synth produces a new
|
||||
* set of measured durations and the storyboard self-aligns — you don't have
|
||||
* set of measured durations and the storyboard self-aligns: you don't have
|
||||
* to retune activity numbers. Author intent stays declarative ("zoom + type
|
||||
* happen during this cue, dwell 4s after, then next cue starts").
|
||||
*/
|
||||
|
|
@ -62,7 +62,7 @@ export interface AdScene {
|
|||
items?: AdSceneItem[];
|
||||
/** Optional single hero photo (URL) shown above the title. */
|
||||
image?: string;
|
||||
/** Optional [left, right] photos for split mode — used by "two streets apart" style ads. */
|
||||
/** Optional [left, right] photos for split mode, used by "two streets apart" style ads. */
|
||||
images?: [string, string];
|
||||
/** Optional caption shown under the image for attribution / context. */
|
||||
imageCaption?: string;
|
||||
|
|
@ -82,7 +82,7 @@ export type Target =
|
|||
/**
|
||||
* Resolved at runtime to the centre of a visible hexagon/postcode polygon,
|
||||
* picked from the dashboard's most recent map response. Robust to any zoom
|
||||
* level — use this when the click MUST land on a polygon and a fixed pixel
|
||||
* level. Use this when the click MUST land on a polygon and a fixed pixel
|
||||
* coordinate would risk landing on a road or river at deep zoom.
|
||||
*/
|
||||
| { kind: 'hexagon' }
|
||||
|
|
@ -195,7 +195,7 @@ export type Activity =
|
|||
| { kind: 'dragSheet'; toHeightFrac: number; durationMs: number }
|
||||
/**
|
||||
* Click the header of a collapsible filter group (e.g. "Transport",
|
||||
* "Schools") so the cards beneath it become visible. Idempotent —
|
||||
* "Schools") so the cards beneath it become visible. Idempotent:
|
||||
* if the group is already open this is a no-op click.
|
||||
*/
|
||||
| { kind: 'openFilterGroup'; selector: string; durationMs: number }
|
||||
|
|
@ -206,7 +206,7 @@ export type Activity =
|
|||
* A narration cue + the activities that play alongside it.
|
||||
*
|
||||
* text : the SPOKEN narration line (TTS input). Never rendered on
|
||||
* screen — what's said must not also be shown.
|
||||
* screen. What's said must not also be shown.
|
||||
* caption : optional SHORT on-screen chip (≤6 words) complementing the
|
||||
* narration. Distinct from `text` by design: visual hooks
|
||||
* ("You can't hear a photo") and stats live here, the story
|
||||
|
|
@ -240,7 +240,7 @@ export interface VideoConfig {
|
|||
captureScale: number;
|
||||
/**
|
||||
* Optional CSS viewport override (in pixels). Lets a single storyboard
|
||||
* record at a narrower CSS viewport than the aspect's default — e.g.
|
||||
* record at a narrower CSS viewport than the aspect's default, e.g.
|
||||
* the recording-*-mobile cuts use 540x960 so Tailwind's `md:`
|
||||
* breakpoint (≥768px) doesn't match and every component picks its
|
||||
* mobile typography. Pair with `captureScale: 2` to keep text sharp.
|
||||
|
|
@ -249,7 +249,7 @@ export interface VideoConfig {
|
|||
viewport?: { width: number; height: number };
|
||||
/**
|
||||
* Visual style of the injected cursor. 'arrow' (default) renders the
|
||||
* classic pointer — right for desktop demos. 'touch' renders a soft
|
||||
* classic pointer, right for desktop demos. 'touch' renders a soft
|
||||
* fingertip dot, which reads as a phone gesture on 9:16 mobile cuts
|
||||
* (an arrow cursor on a phone-shaped video instantly breaks the
|
||||
* illusion that you're watching the mobile product).
|
||||
|
|
@ -340,7 +340,7 @@ export interface TravelTimeFilter {
|
|||
* `post` runs once after the last cue's tail finishes. The cue list is what
|
||||
* gets handed to the synth step.
|
||||
*
|
||||
* `name` doubles as the on-disk slug — outputs go to `output/<name>/` and
|
||||
* `name` doubles as the on-disk slug: outputs go to `output/<name>/` and
|
||||
* publish as `<name>.mp4` + `<name>.jpg`. Keep names URL/path-safe.
|
||||
*/
|
||||
export interface Storyboard {
|
||||
|
|
@ -358,7 +358,7 @@ export interface Storyboard {
|
|||
/**
|
||||
* Frontend viewport in CSS pixels. Defaults to the aspect's native size
|
||||
* (1920x1080 for 16x9, 1080x1920 for 9x16). A storyboard can opt into a
|
||||
* narrower CSS viewport via `video.viewport` — e.g. recording-*-mobile
|
||||
* narrower CSS viewport via `video.viewport`, e.g. recording-*-mobile
|
||||
* uses 540x960 so the frontend's Tailwind `md:` breakpoint doesn't match
|
||||
* and every component picks mobile typography/spacing. Pair the override
|
||||
* with `captureScale: 2` to keep text sharp at the smaller resolution.
|
||||
|
|
@ -375,7 +375,7 @@ export function viewportFor(video: VideoConfig): { width: number; height: number
|
|||
*
|
||||
* Playwright's recordVideo captures the page at its CSS-pixel surface, so
|
||||
* passing a size larger than the viewport just letterboxes the content
|
||||
* into the top-left of an empty frame — not a true high-DPR raster.
|
||||
* into the top-left of an empty frame, not a true high-DPR raster.
|
||||
* Final-resolution upscale (e.g. mobile 540x960 → 1080x1920) is done in
|
||||
* render.sh's ffmpeg pass with `scale=...:flags=lanczos`, which gives a
|
||||
* sharp upscale because Chromium rasterises internally at DPR=captureScale.
|
||||
|
|
@ -387,7 +387,7 @@ export function recordedSizeFor(video: VideoConfig): { width: number; height: nu
|
|||
/**
|
||||
* The final mp4's resolution (after the lanczos upscale pass in render.sh).
|
||||
* Storyboards drive their on-screen typography from CSS viewport sizes, but
|
||||
* social platforms care about the file resolution — so we expose a
|
||||
* social platforms care about the file resolution, so we expose a
|
||||
* separate getter for the published dimensions.
|
||||
*/
|
||||
export function publishedSizeFor(video: VideoConfig): { width: number; height: number } {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ type FormFactor = 'desktop' | 'mobile';
|
|||
* whenever the map is the story.
|
||||
* 4. Filter names MUST match live /api/features exactly (e.g.
|
||||
* "Serious crime (/yr, 7y)", "Distance to nearest amenity (Waitrose)
|
||||
* (km)") — wrong names silently no-op and the map never changes.
|
||||
* (km)"). Wrong names silently no-op and the map never changes.
|
||||
* preflight.ts validates every stubbed name against the live API.
|
||||
*
|
||||
* `name` doubles as the on-disk slug. The pipeline writes per-storyboard
|
||||
|
|
@ -47,14 +47,14 @@ type FormFactor = 'desktop' | 'mobile';
|
|||
// to modelled catchment counts ("Good+ primary school catchments"), which the
|
||||
// local stack already serves; prod still serves the older "…within 2km" names
|
||||
// until that deploy lands. Preflight fails loudly if these drift from
|
||||
// whichever API render.sh is pointed at — flip them back if you render against
|
||||
// whichever API render.sh is pointed at. Flip them back if you render against
|
||||
// prod before the catchment model deploys there.
|
||||
const SCHOOL_GOOD_PRIMARY = 'Good+ primary school catchments';
|
||||
const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary school catchments';
|
||||
|
||||
// Cold-open lean-in on the AI card. Desktop only; kept moderate so the
|
||||
// map remains visible on the right (zoomTo clamps the pan so the app
|
||||
// always covers the full frame — no backdrop voids).
|
||||
// always covers the full frame: no backdrop voids).
|
||||
const AI_ZOOM_SCALE_DESKTOP = 1.45;
|
||||
|
||||
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
|
||||
|
|
@ -62,7 +62,7 @@ const TT_SLIDER_MAX = 120;
|
|||
const TT_DRAG_FROM_MIN = 35;
|
||||
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
|
||||
// enough that street-level central London keeps plenty of matching
|
||||
// postcodes — at 20 the brief emptied the centre and the postcode tap had
|
||||
// postcodes. At 20 the brief emptied the centre and the postcode tap had
|
||||
// nothing fresh to land on (the drawer then opened in its "filtered stats
|
||||
// are empty" fallback).
|
||||
const TT_DRAG_TO_MIN = 25;
|
||||
|
|
@ -356,8 +356,8 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
|||
{ kind: 'cursorScale', scale: isMobile ? 1 : 1.4, durationMs: 200 },
|
||||
{
|
||||
// Zoom AROUND the strongest visible match (hex()) so the
|
||||
// destination area is guaranteed to contain matching postcodes —
|
||||
// a fixed pixel target can dive into a part of town the filters
|
||||
// destination area is guaranteed to contain matching postcodes.
|
||||
// A fixed pixel target can dive into a part of town the filters
|
||||
// just emptied. Settled so the next cue's hex() click projects
|
||||
// against the postcode response for the FINAL viewport.
|
||||
kind: 'mapZoom',
|
||||
|
|
@ -424,7 +424,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
|||
// The drawer's close-button aria-label is localized
|
||||
// (mobileDrawer.closeDrawer), so use the per-locale string.
|
||||
// clickIfVisible keeps a label mismatch from crashing the
|
||||
// take — worst case the drawer lingers behind the zoom-out.
|
||||
// take. Worst case, the drawer lingers behind the zoom-out.
|
||||
kind: 'clickIfVisible',
|
||||
target: el(`button[aria-label="${copy.closeDrawerLabel}"]`),
|
||||
durationMs: 650,
|
||||
|
|
@ -442,7 +442,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
|||
{ kind: 'zoomReset', durationMs: 800 },
|
||||
{
|
||||
// IfVisible: the export button only renders on ≥1024px-wide
|
||||
// dashboards with a licensed user — a missing button must not
|
||||
// dashboards with a licensed user. A missing button must not
|
||||
// crash the take, just skip the ripple.
|
||||
kind: 'clickIfVisible',
|
||||
target: el(`button[title="${copy.exportButtonTitle}"]`),
|
||||
|
|
@ -510,13 +510,13 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
|
|||
outputFps: 50,
|
||||
minDurationS: 10,
|
||||
maxDurationS: 75,
|
||||
// Street-level zoom with the sheet collapsed — the map is the frame.
|
||||
// Street-level zoom with the sheet collapsed: the map is the frame.
|
||||
posterTimeS: 25,
|
||||
};
|
||||
}
|
||||
return {
|
||||
aspect: '16x9',
|
||||
// Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport —
|
||||
// Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport:
|
||||
// the dashboard header switches to tablet sidebar nav between 768 and
|
||||
// 1023px and the Export button (cue 7) disappears.
|
||||
captureScale: 1,
|
||||
|
|
@ -528,7 +528,7 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
|
|||
// Right-pane inspection: London map, filters applied, data pane
|
||||
// FULLY populated (Street View + price history). 31s caught the pane
|
||||
// mid-load on prod (empty white + spinner); 35s lands a few seconds into
|
||||
// the "open" cue once the evidence has rendered — the strongest preview.
|
||||
// the "open" cue once the evidence has rendered: the strongest preview.
|
||||
posterTimeS: 35,
|
||||
};
|
||||
}
|
||||
|
|
@ -545,7 +545,7 @@ function createRecordingStoryboard(
|
|||
// Both form factors open at zoom 12 so the cold-open frame lands on a
|
||||
// densely-populated inner-London slice rather than the low-density outer
|
||||
// edges. Desktop was 11.5, but the opening lean-in panned the visible map
|
||||
// toward the sparse NW suburbs (Wembley/Ealing) — a soft first frame for
|
||||
// toward the sparse NW suburbs (Wembley/Ealing), a soft first frame for
|
||||
// the hero. 12 keeps central London (rich default-density colouring) in
|
||||
// shot from t=0.
|
||||
const initialZoom = 12;
|
||||
|
|
@ -586,10 +586,10 @@ function createRecordingStoryboard(
|
|||
// £600k (not £315k like the old Manchester cut): central London is
|
||||
// pricier, and at £315k the price+crime combo emptied the inner
|
||||
// boroughs. At £600k ~51k postcodes pass in-frame, dropping to ~10k
|
||||
// once the commute is dragged to 25 min — a visible prune that still
|
||||
// once the commute is dragged to 25 min, a visible prune that still
|
||||
// leaves the zoom something to land on (verified via /api/filter-counts).
|
||||
'Estimated current price': [0, 600000],
|
||||
// Loose enough to keep the central-London map richly populated — a
|
||||
// Loose enough to keep the central-London map richly populated. A
|
||||
// cap of 20 emptied the city centre and left the zoom with nothing
|
||||
// to land on.
|
||||
'Serious crime (/yr, 7y)': [0, 40],
|
||||
|
|
@ -646,7 +646,7 @@ const DEMO_STORYBOARDS: Storyboard[] = RECORDING_LOCALES.flatMap((locale) =>
|
|||
// the story, and a fingertip-style cursor sells the gestures as touch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds';
|
||||
type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds' | 'liverpool';
|
||||
|
||||
interface DemoAdCueConfig {
|
||||
text: string;
|
||||
|
|
@ -675,9 +675,11 @@ interface DemoAdStoryboardConfig {
|
|||
travelTimeFilters?: TravelTimeFilter[];
|
||||
posterTimeS?: number;
|
||||
initialZoom?: number;
|
||||
/** Override the city centre (e.g. to frame a specific cheaper-twin pair). */
|
||||
center?: { lat: number; lon: number };
|
||||
/** Plausible per-city "{{count}} properties match" total for the AI summary. */
|
||||
matchCount?: number;
|
||||
/** Activities to run before the cue loop starts (silent — keep short). */
|
||||
/** Activities to run before the cue loop starts (silent, keep short). */
|
||||
prePrime?: Activity[];
|
||||
/** Spoken line during the outro card. Must NOT repeat the card's text. */
|
||||
outroLine: string;
|
||||
|
|
@ -692,7 +694,7 @@ const AD_VIDEO: VideoConfig = {
|
|||
webmBitrate: '4M',
|
||||
outputFps: 50,
|
||||
minDurationS: 8,
|
||||
// Generous upper bound — ads with a deep mapZoom drift a second or two
|
||||
// Generous upper bound: ads with a deep mapZoom drift a second or two
|
||||
// past their declared budgets while tiles load.
|
||||
maxDurationS: 35,
|
||||
posterTimeS: 5,
|
||||
|
|
@ -734,6 +736,7 @@ const CITY_VIEWS: Record<CityKey, { lat: number; lon: number; zoom: number }> =
|
|||
bristol: { lat: 51.3245, lon: -2.5879, zoom: 11.3 },
|
||||
london: { lat: 51.4272, lon: -0.1276, zoom: 10.4 },
|
||||
leeds: { lat: 53.7308, lon: -1.5491, zoom: 11.0 },
|
||||
liverpool: { lat: 53.4084, lon: -2.9916, zoom: 11.2 },
|
||||
};
|
||||
|
||||
// -- small helpers used by the per-ad cue lists -------------------------------
|
||||
|
|
@ -782,7 +785,7 @@ const mapZoomIn = (durationMs = 1500, steps = 5): Activity => ({
|
|||
});
|
||||
/**
|
||||
* Tap the centre of the highest-priority visible postcode polygon from the
|
||||
* latest map response (robust to zoom drift — a fixed pixel target at deep
|
||||
* latest map response (robust to zoom drift: a fixed pixel target at deep
|
||||
* zoom can land on a road or river and the drawer never opens).
|
||||
*/
|
||||
const tapHex = (durationMs = 1000): Activity => ({
|
||||
|
|
@ -814,9 +817,12 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
|
|||
ad.promptText ??
|
||||
'Flat under £350k, good commute, good schools, lower crime, quieter streets';
|
||||
|
||||
const cityView = CITY_VIEWS[ad.city];
|
||||
const initialMapView = {
|
||||
...CITY_VIEWS[ad.city],
|
||||
zoom: ad.initialZoom ?? CITY_VIEWS[ad.city].zoom,
|
||||
...cityView,
|
||||
lat: ad.center?.lat ?? cityView.lat,
|
||||
lon: ad.center?.lon ?? cityView.lon,
|
||||
zoom: ad.initialZoom ?? cityView.zoom,
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -877,7 +883,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
|
|||
|
||||
const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
||||
// -------------------------------------------------------------------
|
||||
// 01 — The hero feature: the whole brief in one sentence.
|
||||
// 01. The hero feature: the whole brief in one sentence.
|
||||
// Cold open ON the typing; map reveal as the payoff.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
|
|
@ -922,7 +928,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 02 — The commute slider. Cold open on a travel-time-coloured map
|
||||
// 02. The commute slider. Cold open on a travel-time-coloured map
|
||||
// (filter + colour applied in pre), then the 60→20 drag is the story.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
|
|
@ -973,8 +979,8 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 03 — Tap a postcode, read its file. The drawer (sold prices, Street
|
||||
// View, schools, crime) is the wow — most viewers don't know this exists.
|
||||
// 03. Tap a postcode, read its file. The drawer (sold prices, Street
|
||||
// View, schools, crime) is the wow. Most viewers don't know this exists.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-03-postcode-files',
|
||||
|
|
@ -1029,7 +1035,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 04 — Noise. The strongest single line in the set; the product proves it.
|
||||
// 04. Noise. The strongest single line in the set; the product proves it.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-04-quiet-streets',
|
||||
|
|
@ -1066,7 +1072,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 05 — Families / the school run. Leeds for non-London variety.
|
||||
// 05. Families / the school run. Leeds for non-London variety.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-05-school-run',
|
||||
|
|
@ -1106,7 +1112,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 06 — Lifestyle amenities. The Waitrose line is the hook; tube + park
|
||||
// 06. Lifestyle amenities. The Waitrose line is the hook; tube + park
|
||||
// make it practical. Names match the live amenity-distance features.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
|
|
@ -1145,7 +1151,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 07 — Renters. Every tool is for buyers; rent is a live feature here.
|
||||
// 07. Renters. Every tool is for buyers; rent is a live feature here.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-07-renters-map',
|
||||
|
|
@ -1185,7 +1191,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 08 — Value. The cost of overpaying for a name vs the one-off fee.
|
||||
// 08. Value. The cost of overpaying for a name vs the one-off fee.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-08-value',
|
||||
|
|
@ -1228,6 +1234,187 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
|
||||
const AD_STORYBOARDS = AD_CONFIGS.map(createDemoAdStoryboard);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cheaper-twin spots: pair-centred value reveals generated from the
|
||||
// analysis/ twin index. Filter names verified against live /api/features.
|
||||
// Render with: VIDEO_STORYBOARD_SET=twins ./render.sh --prod
|
||||
// ---------------------------------------------------------------------------
|
||||
const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
||||
{
|
||||
name: 'twin-beckenham-croydon',
|
||||
matchCount: 1840,
|
||||
city: 'london',
|
||||
center: { lat: 51.38969, lon: -0.04244 },
|
||||
initialZoom: 12.2,
|
||||
promptText: 'Good schools, best value per square metre near Beckenham and Croydon',
|
||||
filters: {
|
||||
'Est. price per sqm': [0, 5200],
|
||||
'Good+ secondary school catchments': [1, 11],
|
||||
},
|
||||
posterTimeS: 7,
|
||||
outroLine: "Beckenham's cheaper twin is on this map. Find yours, free.",
|
||||
cues: [
|
||||
{
|
||||
text: 'Beckenham and Croydon sit side by side: same trains, same school catchment.',
|
||||
caption: 'Same station. Same schools.',
|
||||
during: [
|
||||
typeAct('Good schools, best value per square metre near Beckenham and Croydon', 2600),
|
||||
],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'Rank them by what each pound of floor space actually buys.',
|
||||
caption: 'Ranked by £ per m²',
|
||||
during: [submitSettled(1400)],
|
||||
tail: [sheetDown(800), wait(250)],
|
||||
},
|
||||
{
|
||||
text: 'One postcode over, the same home quietly costs about a third less.',
|
||||
caption: 'The cheaper twin',
|
||||
during: [mapZoomIn(1400, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'twin-ha7-2-vs-ha3-0',
|
||||
matchCount: 1620,
|
||||
city: 'london',
|
||||
center: { lat: 51.59199, lon: -0.3079 },
|
||||
initialZoom: 12.0,
|
||||
promptText: 'Good schools, best value per square metre near Stanmore and Kenton',
|
||||
filters: {
|
||||
'Est. price per sqm': [0, 5900],
|
||||
'Good+ secondary school catchments': [1, 11],
|
||||
},
|
||||
posterTimeS: 7,
|
||||
outroLine: "Stanmore's cheaper twin is on this map. Find yours, free.",
|
||||
cues: [
|
||||
{
|
||||
text: 'Stanmore and Kenton sit right next door, with the same schools and transport links.',
|
||||
caption: 'Same area. Same schools.',
|
||||
during: [typeAct('Good schools, best value per square metre near Stanmore and Kenton', 2600)],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'Rank every postcode by what each pound of floor space actually buys.',
|
||||
caption: 'Ranked by £ per m²',
|
||||
during: [submitSettled(1400)],
|
||||
tail: [sheetDown(800), wait(250)],
|
||||
},
|
||||
{
|
||||
text: 'One postcode over, the same home quietly costs about a sixth less.',
|
||||
caption: 'The cheaper twin',
|
||||
during: [mapZoomIn(1400, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'twin-m40-5-vs-m9-4',
|
||||
matchCount: 2480,
|
||||
city: 'manchester',
|
||||
center: { lat: 53.51293, lon: -2.19574 },
|
||||
initialZoom: 12.0,
|
||||
promptText: 'Good schools, best value per square metre near Newton Heath and Harpurhey',
|
||||
filters: {
|
||||
'Est. price per sqm': [0, 1700],
|
||||
'Good+ secondary school catchments': [1, 11],
|
||||
},
|
||||
posterTimeS: 7,
|
||||
outroLine: "Newton Heath's cheaper twin is on this map. Find yours, free.",
|
||||
cues: [
|
||||
{
|
||||
text: 'Newton Heath and Harpurhey sit right next door, with the same schools and transport links.',
|
||||
caption: 'Same area. Same schools.',
|
||||
during: [typeAct('Good schools, best value per square metre near Newton Heath and Harpurhey', 2600)],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'Rank every postcode by what each pound of floor space actually buys.',
|
||||
caption: 'Ranked by £ per m²',
|
||||
during: [submitSettled(1400)],
|
||||
tail: [sheetDown(800), wait(250)],
|
||||
},
|
||||
{
|
||||
text: 'One postcode over, the same home quietly costs over a third less.',
|
||||
caption: 'The cheaper twin',
|
||||
during: [mapZoomIn(1400, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'twin-l16-7-vs-l14-6',
|
||||
matchCount: 1740,
|
||||
city: 'liverpool',
|
||||
center: { lat: 53.40344, lon: -2.88529 },
|
||||
initialZoom: 12.0,
|
||||
promptText: 'Good schools, best value per square metre near Childwall and Broadgreen',
|
||||
filters: {
|
||||
'Est. price per sqm': [0, 3000],
|
||||
'Good+ secondary school catchments': [1, 11],
|
||||
},
|
||||
posterTimeS: 7,
|
||||
outroLine: "Childwall's cheaper twin is on this map. Find yours, free.",
|
||||
cues: [
|
||||
{
|
||||
text: 'Childwall and Broadgreen sit right next door, with the same schools and transport links.',
|
||||
caption: 'Same area. Same schools.',
|
||||
during: [typeAct('Good schools, best value per square metre near Childwall and Broadgreen', 2600)],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'Rank every postcode by what each pound of floor space actually buys.',
|
||||
caption: 'Ranked by £ per m²',
|
||||
during: [submitSettled(1400)],
|
||||
tail: [sheetDown(800), wait(250)],
|
||||
},
|
||||
{
|
||||
text: 'One postcode over, the same home quietly costs about a third less.',
|
||||
caption: 'The cheaper twin',
|
||||
during: [mapZoomIn(1400, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'twin-rm14-2-vs-rm12-5',
|
||||
matchCount: 1980,
|
||||
city: 'london',
|
||||
center: { lat: 51.54892, lon: 0.22193 },
|
||||
initialZoom: 12.0,
|
||||
promptText: 'Good schools, best value per square metre near Upminster and Hornchurch',
|
||||
filters: {
|
||||
'Est. price per sqm': [0, 5300],
|
||||
'Good+ secondary school catchments': [1, 11],
|
||||
},
|
||||
posterTimeS: 7,
|
||||
outroLine: "Upminster's cheaper twin is on this map. Find yours, free.",
|
||||
cues: [
|
||||
{
|
||||
text: 'Upminster and Hornchurch sit right next door, with the same schools and transport links.',
|
||||
caption: 'Same area. Same schools.',
|
||||
during: [typeAct('Good schools, best value per square metre near Upminster and Hornchurch', 2600)],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'Rank every postcode by what each pound of floor space actually buys.',
|
||||
caption: 'Ranked by £ per m²',
|
||||
during: [submitSettled(1400)],
|
||||
tail: [sheetDown(800), wait(250)],
|
||||
},
|
||||
{
|
||||
text: 'One postcode over, the same home quietly costs about a fifth less.',
|
||||
caption: 'The cheaper twin',
|
||||
during: [mapZoomIn(1400, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard);
|
||||
|
||||
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en';
|
||||
|
||||
export const storyboards: Storyboard[] = (() => {
|
||||
|
|
@ -1238,6 +1425,8 @@ export const storyboards: Storyboard[] = (() => {
|
|||
return DEMO_STORYBOARDS;
|
||||
case 'all':
|
||||
return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS];
|
||||
case 'twins':
|
||||
return TWIN_AD_STORYBOARDS;
|
||||
case 'ads':
|
||||
default:
|
||||
return AD_STORYBOARDS;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Reads two manifests inside ``output/<storyboard>/``:
|
||||
|
||||
* ``audio/index.json`` (synth output) — per-cue WAV filename + measured
|
||||
* ``audio/index.json`` (synth output): per-cue WAV filename + measured
|
||||
duration. Generated BEFORE recording in one batched Qwen3-TTS call.
|
||||
* ``narration.json`` (recorder output) — per-cue ``videoTimeMs`` against
|
||||
* ``narration.json`` (recorder output): per-cue ``videoTimeMs`` against
|
||||
the trimmed video. Generated DURING recording.
|
||||
|
||||
Joins them by ``cueIndex`` (index in the cue list, 1:1 between manifests),
|
||||
|
|
@ -115,7 +115,7 @@ def main() -> int:
|
|||
}
|
||||
)
|
||||
|
||||
# Refuse to mux overlapping cues — amix would silently mash voices on top
|
||||
# Refuse to mux overlapping cues: amix would silently mash voices on top
|
||||
# of each other. Sort by start so the order matches what we'll actually
|
||||
# play, then check that each cue ends before the next one starts.
|
||||
ordered = sorted(items, key=lambda it: it["videoTimeMs"])
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ dependencies = [
|
|||
"qwen-tts>=0.1.1",
|
||||
# Host driver is CUDA 12.4 (see `nvidia-smi`). torch 2.7+ dropped cu124
|
||||
# wheels, so we cap below that and pull the cu124 build from PyTorch's
|
||||
# own index (configured below). torchaudio must match torch's CUDA build
|
||||
# — the PyPI default ships a CUDA 13 binary that fails to load
|
||||
# own index (configured below). torchaudio must match torch's CUDA build:
|
||||
# the PyPI default ships a CUDA 13 binary that fails to load
|
||||
# libcudart.so.13 on this host.
|
||||
"torch>=2.5,<2.7",
|
||||
"torchaudio>=2.5,<2.7",
|
||||
|
|
@ -20,7 +20,7 @@ dependencies = [
|
|||
# Flash-attention prebuilt wheel matched to torch 2.6 + cu12 + cp312, old
|
||||
# CXX ABI (PyTorch's cu124 wheel reports compiled_with_cxx11_abi() == False
|
||||
# and only exports the old-ABI c10::Error constructor). Pinned to
|
||||
# 2.7.4.post1 because 2.8.x's torch2.6/abiFALSE wheels were mislabelled —
|
||||
# 2.7.4.post1 because 2.8.x's torch2.6/abiFALSE wheels were mislabelled:
|
||||
# they ship new-ABI symbols and fail to import. Building from source needs
|
||||
# nvcc which isn't on the host. Enable via `uv sync --extra gpu`; render.sh
|
||||
# does this automatically when nvidia-smi reports a GPU.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Reads ``output/<storyboard>/narration-script.json`` (emitted by
|
||||
``dist/preflight.js``) and runs ``Qwen3TTSModel.generate_voice_design`` with
|
||||
all cue texts as a single batched list — that way every cue shares the same
|
||||
all cue texts as a single batched list: that way every cue shares the same
|
||||
model state, which keeps prosody and timbre consistent across cues. Per-cue
|
||||
WAVs and an index manifest go to ``output/<storyboard>/audio/`` for the
|
||||
recording step (which reads measured cue durations) and the mux step (which
|
||||
|
|
@ -16,7 +16,7 @@ production runs.
|
|||
We use the VoiceDesign sibling of CustomVoice because it accepts a free-form
|
||||
voice persona (British accent, narrator register, "no laughter") via the
|
||||
``instruct`` parameter. CustomVoice's preset speakers are all American or
|
||||
non-English, and its ``instruct`` is documented for emotion only — it
|
||||
non-English, and its ``instruct`` is documented for emotion only. It
|
||||
ignored accent directives and bled non-speech tokens (laughter, sighs)
|
||||
between cues.
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ from qwen_tts import Qwen3TTSModel
|
|||
|
||||
# Two checkpoints: the design model mints the reference clip in the desired
|
||||
# persona; the clone model conditions every cue on that reference's x-vector.
|
||||
# Neither CustomVoice nor VoiceDesign support generate_voice_clone — only the
|
||||
# Neither CustomVoice nor VoiceDesign support generate_voice_clone. Only the
|
||||
# Base checkpoint does.
|
||||
DEFAULT_DESIGN_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
||||
DEFAULT_CLONE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
|
|
@ -69,8 +69,8 @@ def _file_sha256(path: Path) -> str:
|
|||
"""Content hash of a file, used to pin cached cues to a reference WAV.
|
||||
|
||||
The cue cache keys off the *settings* that produced the reference
|
||||
(instruct/seed/…), but a re-mint of VoiceDesign — or render.sh copying a
|
||||
different storyboard's reference into this audio dir — can swap the actual
|
||||
(instruct/seed/…), but a re-mint of VoiceDesign (or render.sh copying a
|
||||
different storyboard's reference into this audio dir) can swap the actual
|
||||
reference waveform out from under those settings. Cloning some cues from
|
||||
reference A and others from reference B yields two audibly different
|
||||
speakers in one video. Hashing the bytes of the reference that was
|
||||
|
|
@ -328,7 +328,7 @@ def _resolve_reference(
|
|||
sf.write(str(ref_wav_path), ref_audio, ref_sr)
|
||||
ref_meta_path.write_text(json.dumps(ref_meta, indent=2))
|
||||
|
||||
# Free the design model before loading the clone model — both are 1.7B,
|
||||
# Free the design model before loading the clone model: both are 1.7B,
|
||||
# we don't want them resident at the same time.
|
||||
del design_model
|
||||
if torch.cuda.is_available():
|
||||
|
|
@ -357,7 +357,7 @@ def main() -> int:
|
|||
voice = script.get("voice")
|
||||
if not voice:
|
||||
print(
|
||||
f"[synth] {script_path} has no `voice` block — re-run preflight.",
|
||||
f"[synth] {script_path} has no `voice` block. Re-run preflight.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
|
@ -386,7 +386,7 @@ def main() -> int:
|
|||
# (or the user supplies one via --reference-audio).
|
||||
# 2. Base + generate_voice_clone(x_vector_only_mode=True) conditions
|
||||
# every cue on the reference's speaker embedding.
|
||||
# Without (2), batched generation drifts timbre across cues — a persona
|
||||
# Without (2), batched generation drifts timbre across cues: a persona
|
||||
# prompt anchors style but not identity, so each batch item picks its
|
||||
# own voice. The reference WAV is cached so subsequent runs only load
|
||||
# the clone model (saves ~20s + 3.4 GB of disk download); when it is
|
||||
|
|
@ -398,13 +398,13 @@ def main() -> int:
|
|||
# Pin the cue cache to this exact reference waveform. A re-mint (or a
|
||||
# reference copied in from another storyboard by render.sh) changes these
|
||||
# bytes; without this gate the cue cache would keep cloned-from-the-old-
|
||||
# reference WAVs alongside freshly regenerated ones — two voices in one
|
||||
# reference WAVs alongside freshly regenerated ones: two voices in one
|
||||
# video. See _file_sha256.
|
||||
reference_hash = _file_sha256(ref_wav_path)
|
||||
|
||||
index_path = audio_dir / "index.json"
|
||||
|
||||
# Skip generation when the existing audio matches the script — same cue
|
||||
# Skip generation when the existing audio matches the script: same cue
|
||||
# texts and same gapBeforeMs values in the same order, AND same synth
|
||||
# settings (instruct/language/reference text + reference-WAV hash/model/
|
||||
# seed/temperature/top_p). Saves ~30s of GPU time when iterating on
|
||||
|
|
@ -424,7 +424,7 @@ def main() -> int:
|
|||
top_p,
|
||||
):
|
||||
print(
|
||||
f"[synth] [{args.storyboard}] cached audio matches the current script — skipping generation",
|
||||
f"[synth] [{args.storyboard}] cached audio matches the current script: skipping generation",
|
||||
flush=True,
|
||||
)
|
||||
return 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue