← 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)