← back to Greenland Onboard

scripts/make-cork-reels.py

77 lines

#!/usr/bin/env python3
# Build one branded 9:16 reel per cork COASTAL-CITY pattern from local cork images.
# Local only ($0): ImageMagick per-frame branding + ffmpeg xfade slideshow. No posting.
import json, re, subprocess, os, collections, math, shutil
ROOT = os.path.expanduser('~/Projects/greenland-onboard')
IMG = f'{ROOT}/public/img'
OUT = os.path.expanduser('~/Videos/cork-reels')
TMP = '/tmp/cork-reel-frames'
os.makedirs(OUT, exist_ok=True)
DB = 'postgresql:///dw_unified?host=/tmp&user=stevestudio2'
FONT = '/System/Library/Fonts/Supplemental/Georgia.ttf'
FONTB = '/System/Library/Fonts/Supplemental/Georgia Bold.ttf'
CITY = {'S300':'Montauk','H171':'Nantucket','G0174':'Newport','S176':'Sag Harbor','G0181':'Cape May',
        'G0155':'Palm Beach','G0111':'Kiawah','G0110':'Hilton Head','G0182':'Chatham','G0154':'Southampton',
        'G0148':'Amagansett','H192':"Martha's Vineyard"}
MAXF = 15  # frames per reel (evenly sampled if more)
DUR, XF = 2.5, 0.5

def series(m):
    x = re.match(r'^[A-Za-z]+[0-9]{2,4}', (m or '').strip()); return x.group(0) if x else ''
def tc(s): return re.sub(r'\w\S*', lambda w: w.group(0)[:1].upper()+w.group(0)[1:].lower(), (s or '').strip())

rows = json.loads(subprocess.check_output(['/opt/homebrew/opt/postgresql@14/bin/psql', DB, '-tAc',
  "select coalesce(json_agg(json_build_object('dw',dw_sku,'mfr',mfr_sku,'color',color_name) order by dw_sku)::text,'[]') from greenland_catalog"]).decode().strip())

by = collections.OrderedDict()
for r in rows:
    c = CITY.get(series(r['mfr']))
    if not c: continue
    img = f"{IMG}/{r['dw']}.jpg"
    if os.path.exists(img): by.setdefault(c, []).append((img, tc(r['color'])))

def sample(lst, n):
    return lst if len(lst) <= n else [lst[round(i*(len(lst)-1)/(n-1))] for i in range(n)]

def brand(src, dst, city, color):
    subprocess.run(['magick', src, '-resize', '1080x1920^', '-gravity', 'center', '-extent', '1080x1920',
        # bottom scrim for legibility
        '(', '-size', '1080x620', 'gradient:none-black', ')', '-gravity', 'south', '-compose', 'over', '-composite',
        # top scrim
        '(', '-size', '1080x360', 'gradient:black-none', ')', '-gravity', 'north', '-compose', 'over', '-composite',
        '-font', FONTB, '-fill', 'white', '-gravity', 'north', '-pointsize', '96', '-annotate', '+0+120', city,
        '-font', FONT, '-fill', '#e8dcc8', '-pointsize', '40', '-annotate', '+0+240', 'Cork Wallcovering',
        '-font', FONT, '-fill', 'white', '-gravity', 'south', '-pointsize', '58', '-annotate', '+0+150', color,
        '-font', FONTB, '-fill', '#c9a86a', '-gravity', 'south', '-pointsize', '30', '-annotate', '+0+70',
        'PHILLIPE ROMANO', '-quality', '92', dst], check=True)

made = []
for city, items in by.items():
    frames = sample(items, MAXF)
    fdir = f'{TMP}/{re.sub(r"[^a-z0-9]","-",city.lower())}'
    shutil.rmtree(fdir, ignore_errors=True); os.makedirs(fdir)
    prepped = []
    for i, (src, color) in enumerate(frames):
        dst = f'{fdir}/f{i:03d}.jpg'; brand(src, dst, city, color); prepped.append(dst)
    # ffmpeg xfade chain
    inp, filt, off = [], '', DUR - XF
    for i, p in enumerate(prepped):
        inp += ['-loop', '1', '-t', str(DUR), '-i', p]
    for i in range(1, len(prepped)):
        prev = '0' if i == 1 else f'v{i-1}'
        filt += f'[{prev}][{i}]xfade=transition=fade:duration={XF}:offset={off:.2f}[v{i}];'
        off += DUR - XF
    last = f'v{len(prepped)-1}' if len(prepped) > 1 else '0'
    filt += f'[{last}]format=yuv420p,fps=30[out]'
    outdir = f'{OUT}/{re.sub(r"[^A-Za-z0-9]","_",city)}'; os.makedirs(outdir, exist_ok=True)
    outfile = f'{outdir}/reel-9x16.mp4'
    subprocess.run(['ffmpeg', '-y', *inp, '-filter_complex', filt, '-map', '[out]',
        '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '22', '-preset', 'medium', '-t', '59', outfile],
        check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    dur = round((len(prepped)*(DUR-XF))+XF, 1)
    made.append((city, len(prepped), outfile, dur))
    print(f'  ✓ {city}: {len(prepped)} frames → {outfile} (~{dur}s)')

print(f'\nDONE {len(made)} cork reels → {OUT}')
json.dump([{'city':c,'frames':f,'file':o,'seconds':d} for c,f,o,d in made], open(f'{OUT}/manifest.json','w'), indent=1)