Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
214 lines
14 KiB
Python
214 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Orbit's next interface: custom icons, main menu, grid guide and watching screen.
|
|
|
|
Writes SVG sources, then native VectorCraft icons and FigCraft screens. Programmes shown are fictional.
|
|
Yellow stays reserved for remote focus, as in the existing app.
|
|
"""
|
|
from pathlib import Path
|
|
import base64, glob, html, math, os, re, subprocess
|
|
P = Path(__file__).resolve().parent
|
|
ROOT = P.parent.parent
|
|
COLLECTION = ROOT.parent / '2026-10-31/referenced-chatgpt-conversation-this-is-an/Orbit-Channel-Designs'
|
|
ENV = dict(os.environ, XDG_DATA_HOME=str(ROOT / '.tooling/font-data'))
|
|
NAVY, PANEL, BLUE, FOCUS, MUTED, SILVER = '#071632', '#12356B', '#194C91', '#FFDE59', '#B7CBE8', '#E8EDF5'
|
|
(P / 'icons').mkdir(exist_ok=True); (P / 'sources').mkdir(exist_ok=True); (P / 'review').mkdir(exist_ok=True)
|
|
|
|
|
|
def svg(w, h, body):
|
|
return f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{w}" height="{h}" viewBox="0 0 {w} {h}">{body}</svg>'
|
|
|
|
|
|
def text(x, y, value, size=24, fill='white', weight='400', family='Barlow', anchor='start', extra=''):
|
|
return f'<text x="{x}" y="{y}" font-family="{family}" font-size="{size}" font-weight="{weight}" fill="{fill}" text-anchor="{anchor}" {extra}>{html.escape(value)}</text>'
|
|
|
|
|
|
def rect(x, y, w, h, fill, r=0, extra=''):
|
|
return f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" fill="{fill}" {extra}/>'
|
|
|
|
|
|
def gear_path():
|
|
points, teeth = [], 8
|
|
for i in range(teeth * 4):
|
|
a = 2 * math.pi * i / (teeth * 4) - math.pi / 8
|
|
r = 40 if i % 4 in (0, 1) else 31
|
|
points.append(f'{48 + r * math.cos(a):.1f},{48 + r * math.sin(a):.1f}')
|
|
return ' '.join(points)
|
|
|
|
|
|
def icon(name, c, accent):
|
|
s = f'fill="none" stroke="{c}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"'
|
|
a = f'fill="none" stroke="{accent}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"'
|
|
return {
|
|
'all': f'<rect x="10" y="26" width="76" height="56" rx="9" {s}/><path d="M34 26L24 10M62 26L72 10" {s}/><path d="M26 66C40 72 62 60 70 42" {a}/><circle cx="70" cy="42" r="3.5" fill="{accent}"/>',
|
|
'entertainment': f'<rect x="10" y="20" width="76" height="56" rx="9" {s}/><path d="M34 88H62" {s}/><path d="M48 32L52.700 42.500 64 43.700 55.500 51.300 57.900 62.500 48 56.700 38.100 62.500 40.500 51.300 32 43.700 43.300 42.500Z" {a}/>',
|
|
'movies': f'<rect x="12" y="38" width="72" height="46" rx="6" {s}/><path d="M12 38L78 18 84 32 18 52" {s}/><path d="M32 32L38 44M50 26L56 40M66 22L72 36" {a}/>',
|
|
'comedy': f'<circle cx="48" cy="48" r="36" {s}/><circle cx="35" cy="40" r="3.800" fill="{c}"/><circle cx="61" cy="40" r="3.800" fill="{c}"/><path d="M28 54C34 74 62 74 68 54Z" {a}/>',
|
|
'kids': f'<path d="M48 8C66 22 68 48 62 68H34C28 48 30 22 48 8Z" {s}/><circle cx="48" cy="36" r="8" {a}/><path d="M34 54L18 70 32 72M62 54L78 70 64 72M42 80V90M54 80V90" {s}/>',
|
|
'documentaries': f'<circle cx="44" cy="52" r="34" {s}/><path d="M12 42C28 50 36 38 44 46S40 70 50 84M62 24C56 34 66 40 76 40" {s}/><path d="M60 24C66 8 80 6 90 8 90 22 82 34 66 32Z" {a}/>',
|
|
'sport': f'<path d="M14 30V84M82 30V84" {s}/><path d="M14 42H82M14 56H82M14 70H82" {a}/><circle cx="14" cy="26" r="5" fill="{c}"/><circle cx="82" cy="26" r="5" fill="{c}"/>',
|
|
'gear': f'<polygon points="{gear_path()}" {s}/><circle cx="48" cy="48" r="12" {a}/>',
|
|
'profile': f'<circle cx="48" cy="33" r="19" fill="{c}"/><path d="M12 86C12 64 28 56 48 56S84 64 84 86Z" fill="{c}"/>',
|
|
'office': f'<path d="M10 28H86V42C78 42 78 54 86 54V68H10V54C18 54 18 42 10 42Z" {s}/><path d="M60 30V66" {a} stroke-dasharray="5 7"/><path d="M26 42H48M26 54H42" {s}/>',
|
|
}[name]
|
|
|
|
|
|
ICONS = ['all', 'entertainment', 'movies', 'comedy', 'kids', 'documentaries', 'sport', 'gear', 'office', 'profile']
|
|
for name in ICONS:
|
|
src = P / 'icons' / f'orbit_icon_{name}.svg'
|
|
src.write_text(svg(96, 96, icon(name, SILVER, '#7FB2FF')))
|
|
for out, options in ((src.with_suffix('.vectorcraft'), []), (src.with_suffix('.png'), ['--scale', '2'])):
|
|
subprocess.run(['vectorcraft-cli', 'convert', str(src), str(out), *options], env=ENV, check=True, stdout=subprocess.DEVNULL)
|
|
|
|
|
|
# Orbit's own mark: the same crescent and orbit every channel carries, in the network's blue and silver.
|
|
ORBIT_DEFS = '<defs><linearGradient id="orbitsilver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs>'
|
|
ORBIT_MARK = '<path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#orbitsilver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#5FA8FF" stroke-width="13"/><circle cx="430" cy="282" r="11" fill="#5FA8FF"/>'
|
|
|
|
|
|
def orbit_logo(x, y, height):
|
|
"""Mark and wordmark, height tall. Drawn 640 by 160."""
|
|
return (f'<g transform="translate({x},{y}) scale({height / 160})">' + ORBIT_DEFS + f'<g transform="translate(-14,-22) scale(.36)">{ORBIT_MARK}</g>' +
|
|
text(168, 118, 'ORBIT', 104, 'url(#orbitsilver)', '500', 'Barlow Condensed', extra='letter-spacing="14"') + '</g>')
|
|
|
|
|
|
for name, body in (('orbit_logo', svg(640, 160, orbit_logo(0, 0, 160))),
|
|
('orbit_mark', svg(512, 512, ORBIT_DEFS + f'<g transform="translate(-24,-30) scale(1.08)">{ORBIT_MARK}</g>'))):
|
|
src = P / 'icons' / f'{name}.svg'
|
|
src.write_text(body)
|
|
for out, options in ((src.with_suffix('.vectorcraft'), []), (src.with_suffix('.png'), [])):
|
|
subprocess.run(['vectorcraft-cli', 'convert', str(src), str(out), *options], env=ENV, check=True, stdout=subprocess.DEVNULL)
|
|
|
|
|
|
def use_icon(name, x, y, size, colour=SILVER, accent='#7FB2FF'):
|
|
return f'<g transform="translate({x},{y}) scale({size / 96})">{icon(name, colour, accent)}</g>'
|
|
|
|
|
|
counter = [0]
|
|
|
|
|
|
def logo(folder, x, y, width):
|
|
"""Inline a channel's outlined guide logo, with its ids made unique for this page."""
|
|
counter[0] += 1
|
|
found = glob.glob(str(COLLECTION / folder / 'exports/*_epg.svg'))[0]
|
|
s = open(found).read()
|
|
inner = s[s.index('>', s.index('<svg')) + 1:s.rindex('</svg>')]
|
|
for name in set(re.findall(r'id="([^"]+)"', inner)):
|
|
inner = inner.replace(f'id="{name}"', f'id="l{counter[0]}{name}"').replace(f'url(#{name})', f'url(#l{counter[0]}{name})').replace(f'href="#{name}"', f'href="#l{counter[0]}{name}"')
|
|
return f'<g transform="translate({x},{y}) scale({width / 512})">{inner}</g>'
|
|
|
|
|
|
def still():
|
|
out = P / 'sources/still.jpg'
|
|
subprocess.run(['ffmpeg', '-y', '-v', 'error', '-ss', '1.2', '-i', str(COLLECTION / 'depth-native/city/heroes/heroes_city.mp4'), '-frames:v', '1', '-q:v', '3', str(out)], check=True)
|
|
return 'data:image/jpeg;base64,' + base64.b64encode(out.read_bytes()).decode()
|
|
|
|
|
|
STILL = still()
|
|
|
|
|
|
def picture(x, y, w, h):
|
|
return f'<image x="{x}" y="{y}" width="{w}" height="{h}" preserveAspectRatio="xMidYMid slice" href="{STILL}"/>'
|
|
|
|
|
|
def top_bar(title='orbit'):
|
|
return (orbit_logo(60, 26, 76) +
|
|
text(1620, 74, 'Sun 1 Nov 20:14', 28, 'white', '600', anchor='end') + rect(1652, 28, 76, 68, PANEL, 12) + use_icon('gear', 1664, 36, 52) + rect(1748, 28, 76, 68, PANEL, 12) + use_icon('profile', 1762, 38, 48))
|
|
|
|
|
|
def legend(items, y=1040, x=60):
|
|
out = ''
|
|
for key, label in items:
|
|
out += text(x, y, key, 24, 'white', '600')
|
|
x += 18 + (32 * len(key) if key[0] in '▲▼◄►' else 15 * len(key))
|
|
out += text(x, y, label, 22, MUTED)
|
|
x += 40 + 11 * len(label)
|
|
return out
|
|
|
|
|
|
def category_rows(selected, y=132, x=60, w=350):
|
|
out = ''
|
|
for i, (name, label) in enumerate([('all', 'All Channels'), ('entertainment', 'Entertainment'), ('movies', 'Movies'), ('comedy', 'Comedy'), ('kids', 'Kids'), ('documentaries', 'Documentaries'), ('sport', 'Sport')]):
|
|
ry = y + i * 106
|
|
on = i == selected
|
|
out += rect(x, ry, w, 94, FOCUS if on else PANEL, 10)
|
|
out += use_icon(name, x + 16, ry + 19, 56, NAVY if on else SILVER, NAVY if on else '#7FB2FF')
|
|
out += text(x + 90, ry + 58, label, 30, NAVY if on else 'white', '600')
|
|
return out
|
|
|
|
|
|
def progress(x, y, w, fraction):
|
|
return rect(x, y, w, 8, '#0B2452', 4) + rect(x, y, w * fraction, 8, SILVER, 4)
|
|
|
|
|
|
# Main menu: television first. One live picture, and two small promises beside it.
|
|
home = rect(0, 0, 1920, 1080, NAVY) + top_bar() + category_rows(0)
|
|
home += picture(440, 132, 860, 484) + rect(440, 132, 860, 484, 'none', 0, f'stroke="{BLUE}" stroke-width="3"')
|
|
home += rect(440, 628, 860, 234, PANEL, 10) + logo('heroes', 456, 650, 260)
|
|
home += text(736, 676, 'NOW 20:00 to 20:30', 24, MUTED, '600') + text(736, 726, 'The Night Watch', 44, 'white', '600') + progress(736, 750, 540, .47) + text(736, 806, 'NEXT 20:30 City Under Siege', 26, MUTED)
|
|
home += text(1330, 158, 'On now', 28, 'white', '600')
|
|
for i, (folder, title, frac) in enumerate([('orbit-terra', 'Life on the Ice Shelf', .62), ('paradox', 'The Signal Room', .2), ('orbit-gold', 'Mind the Shop', .81)]):
|
|
ry = 176 + i * 112
|
|
home += rect(1330, ry, 530, 102, PANEL, 10) + logo(folder, 1338, ry + 16, 220) + text(1566, ry + 46, title, 25, 'white', '600') + progress(1566, ry + 66, 276, frac)
|
|
home += rect(1330, 522, 530, 340, PANEL, 10) + rect(1330, 522, 530, 62, '#7A5A14', 10) + rect(1330, 560, 530, 24, '#7A5A14')
|
|
home += use_icon('office', 1344, 530, 46, '#FFF3C4', '#FFF3C4') + text(1402, 564, 'Tonight at OrbitOffice', 28, '#FFF3C4', '600')
|
|
home += text(1356, 644, 'Late Night Spooks', 50, 'white', '500', 'Barlow Condensed') + text(1356, 692, 'Three films from 21:00', 26, MUTED)
|
|
home += text(1356, 748, 'The Last Screening', 25, 'white') + text(1356, 786, 'Midnight Visitors', 25, 'white') + text(1356, 824, 'The Hollow House', 25, 'white')
|
|
home += legend([('▲▼', 'Move'), ('OK', 'Select'), ('►', 'Picture and events'), ('Back', 'Full screen')], 960)
|
|
(P / 'sources/orbit_main_menu.svg').write_text(svg(1920, 1080, home))
|
|
|
|
# Grid guide: picture one side, the highlighted programme the other, channels and times below.
|
|
guide = rect(0, 0, 1920, 1080, NAVY) + top_bar()
|
|
guide += picture(60, 116, 576, 324) + rect(60, 116, 576, 324, 'none', 0, f'stroke="{BLUE}" stroke-width="3"')
|
|
guide += logo('orbit-odyssey', 660, 112, 300) + text(660, 240, 'Under Northern Stars', 52, 'white', '600')
|
|
guide += text(660, 286, '20:30 to 21:30 • Sun 1 Nov • Orbit Odyssey', 26, MUTED, '600')
|
|
guide += text(660, 336, 'A crew of astronomers follow a once-in-a-century comet across the Arctic,', 27, 'white') + text(660, 372, 'racing the polar night to capture it from the top of the world.', 27, 'white')
|
|
guide += text(660, 422, 'OK Watch • Hold OK Go to channel number', 23, MUTED)
|
|
gx, gy, rowh, left = 400, 462, 90, 60
|
|
guide += rect(left, gy, 330, 48, PANEL) + text(left + 18, gy + 34, 'All Channels', 26, 'white', '600') + use_icon('all', left + 278, gy + 6, 36)
|
|
span = 1860 - gx
|
|
times = ['20:00', '20:30', '21:00', '21:30']
|
|
guide += rect(gx - 10, gy, span + 10, 48, PANEL)
|
|
for i, t in enumerate(times):
|
|
guide += text(gx + i * span / 4 + 10, gy + 34, t, 25, 'white', '600')
|
|
rows = [(1, 'orbit-1', [(0, 1, 'The Evening Exchange'), (1, 2, 'Family Favourites'), (3, 1, 'Home Run')]),
|
|
(21, 'heroes', [(0, 1, 'The Night Watch'), (1, 1, 'City Under Siege'), (2, 2, 'The Silver Shield')]),
|
|
(34, 'orbit-odyssey', [(0, 1, 'Maps of the Deep'), (1, 2, 'Under Northern Stars'), (3, 1, 'Engines')]),
|
|
(40, 'orbit-terra', [(0, 2, 'Life on the Ice Shelf'), (2, 2, 'The Long Migration')]),
|
|
(52, 'paradox', [(0, 1, 'The Signal Room'), (1, 1, 'Mirror Town'), (2, 1, 'The Static Hour'), (3, 1, 'Lost Hours')])]
|
|
for i, (number, folder, shows) in enumerate(rows):
|
|
ry = gy + 56 + i * rowh
|
|
guide += rect(left, ry, 330, rowh - 8, PANEL) + text(left + 16, ry + 52, f'{100 + number}', 26, MUTED, '600') + logo(folder, left + 66, ry + 2, 250)
|
|
for start, length, title in shows:
|
|
focused = title == 'Under Northern Stars'
|
|
x = gx + start * span / 4
|
|
width = length * span / 4 - 6
|
|
guide += rect(x, ry, width, rowh - 8, FOCUS if focused else (BLUE if start == 0 else '#102D5E'))
|
|
guide += text(x + 16, ry + 50, title, 27, NAVY if focused else 'white', '600' if focused else '400')
|
|
now_x = gx + span * .47 / 2
|
|
guide += rect(now_x - 1, gy + 48, 3, 5 * rowh + 2, '#FFFFFF', 0, 'opacity=".85"')
|
|
guide += legend([('▲▼', 'Channel'), ('◄►', 'Time'), ('OK', 'Watch'), ('Hold OK', 'Number'), ('Back', 'Categories')], 1050)
|
|
(P / 'sources/orbit_grid_guide.svg').write_text(svg(1920, 1080, guide))
|
|
|
|
# Watching: the classic bar on OK, and the small number box on hold.
|
|
paper = SILVER
|
|
watch = picture(0, 0, 1920, 1080)
|
|
watch += text(1820, 110, '134', 84, '#47FF52', '700', 'monospace', 'end')
|
|
watch += rect(760, 90, 400, 170, NAVY, 14, f'stroke="{FOCUS}" stroke-width="3"') + text(960, 136, 'Go to channel', 28, MUTED, '600', anchor='middle')
|
|
for i, d in enumerate(['1', '3', '_']):
|
|
watch += rect(838 + i * 84, 156, 72, 84, FOCUS if i == 2 else PANEL, 8) + text(874 + i * 84, 216, d, 54, NAVY if i == 2 else 'white', '600', anchor='middle')
|
|
watch += rect(132, 780, 1656, 46, paper) + text(158, 812, '134 Orbit Odyssey', 25, NAVY, '600') + text(1762, 812, '20:42 Sun 1 Nov', 24, NAVY, anchor='end')
|
|
watch += rect(132, 826, 1656, 128, BLUE) + text(160, 876, 'NOW', 24, FOCUS, '600') + text(290, 876, 'Under Northern Stars', 38, 'white', '600') + progress(1270, 866, 490, .2)
|
|
watch += text(160, 930, 'NEXT', 22, MUTED, '600') + text(290, 930, '21:30 Engines', 27, 'white')
|
|
watch += rect(132, 954, 1656, 58, paper)
|
|
x = 160
|
|
for key, label in [('▲▼', 'Channel'), ('◄', 'Last channel'), ('►', 'Subtitles'), ('Hold OK', 'Number'), ('Back', 'TV guide')]:
|
|
watch += text(x, 994, key, 25, NAVY, '600'); x += 18 + (32 * len(key) if key[0] in '▲▼◄►' else 16 * len(key))
|
|
watch += text(x, 994, label, 25, NAVY, '600'); x += 60 + 13 * len(label)
|
|
(P / 'sources/orbit_watching.svg').write_text(svg(1920, 1080, watch))
|
|
|
|
for name in ('orbit_main_menu', 'orbit_grid_guide', 'orbit_watching'):
|
|
native = P / 'review' / f'{name}.figcraft'
|
|
subprocess.run(['figcraft-cli', 'convert', str(P / 'sources' / f'{name}.svg'), str(native)], env=ENV, check=True, stdout=subprocess.DEVNULL)
|
|
subprocess.run(['figcraft-cli', 'export', str(native), '--out', str(native.with_suffix('.png'))], env=ENV, check=True, stdout=subprocess.DEVNULL)
|
|
print('built')
|