#!/usr/bin/env python3 """Portada y esquemas del README, con la paleta del tema Acampada. Las capturas de movil (01..08) se hacen aparte, con la app arrancada en un nodo de pruebas a 412x915 y escala 2; este script compone 00-portada.png a partir de ellas y dibuja esquema-interfaz.png, esquema-rama.png y esquema-llamadas.png. El texto vive aqui: se cambia y se relanza, nunca se retocan los PNG a mano. python3 docs/capturas/generar_portada.py """ import math from pathlib import Path from PIL import Image, ImageDraw, ImageFont HERE = Path(__file__).resolve().parent ANTON = HERE.parents[1] / "src/client/assets/fonts/Anton-Regular.ttf" SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" BG, FG, NEON, MUTED = "#ffffff", "#000000", "#00e5ff", "#555555" def font(path, size): return ImageFont.truetype(str(path), size) def phone(shot, width): """Una captura dentro de un marco negro grueso con la sombra cian de los botones.""" im = Image.open(HERE / shot).convert("RGB") h = round(im.height * width / im.width) im = im.resize((width, h), Image.LANCZOS) pad, radius, shadow = 10, 34, 10 frame = Image.new("RGBA", (width + 2 * pad, h + 2 * pad + shadow), (0, 0, 0, 0)) d = ImageDraw.Draw(frame) d.rounded_rectangle((0, shadow, width + 2 * pad - 1, h + 2 * pad + shadow - 1), radius, fill=NEON) d.rounded_rectangle((0, 0, width + 2 * pad - 1, h + 2 * pad - 1), radius, fill=FG) mask = Image.new("L", im.size, 0) ImageDraw.Draw(mask).rounded_rectangle((0, 0, width - 1, h - 1), radius - pad, fill=255) frame.paste(im, (pad, pad), mask) return frame def portada(): W, H = 1600, 1100 out = Image.new("RGB", (W, H), BG) d = ImageDraw.Draw(out) d.text((80, 40), "OASIS MOBILE", font=font(ANTON, 92), fill=FG) bottom = d.textbbox((80, 40), "OASIS MOBILE", font=font(ANTON, 92))[3] d.rectangle((80, bottom + 12, 420, bottom + 20), fill=FG) d.text((80, 196), "Rama PRUEBAS del hacklab · base Oasis 1.3.3 · tema Acampada, pensado para el pulgar", font=font(SANS, 27), fill=MUTED) shots = [("01-portada.png", "Actividad al abrir"), ("02-tribus.png", "Tribus en filas"), ("03-votacion.png", "Votar en semáforo"), ("07-oasismobile.png", "El menú, tema del dev")] pw, gap, top = 320, 50, 270 x = (W - (4 * (pw + 20) + 3 * gap)) // 2 cap = font(ANTON, 34) for shot, label in shots: fr = phone(shot, pw) out.paste(fr, (x, top), fr) tw = d.textlength(label.upper(), font=cap) d.text((x + (fr.width - tw) / 2, top + fr.height + 18), label.upper(), font=cap, fill=FG) x += fr.width + gap out.save(HERE / "00-portada.png", optimize=True) def caja(d, box, titulo, lineas, relleno=BG): x0, y0, x1, y1 = box d.rounded_rectangle((x0, y0 + 6, x1, y1 + 6), 18, fill=FG) d.rounded_rectangle(box, 18, fill=relleno, outline=FG, width=4) t = font(ANTON, 30) d.text(((x0 + x1 - d.textlength(titulo, font=t)) / 2, y0 + 18), titulo, font=t, fill=FG) s = font(SANS, 18) for i, l in enumerate(lineas): d.text(((x0 + x1 - d.textlength(l, font=s)) / 2, y0 + 70 + i * 26), l, font=s, fill=FG) def esquema(): W, H = 1400, 840 out = Image.new("RGB", (W, H), BG) d = ImageDraw.Draw(out) d.text((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46), fill=FG) bottom = d.textbbox((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46))[3] d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG) d.text((46, bottom + 30), "Casi sesenta módulos no caben en una pantalla de teléfono. Lo primero es lo que pasa; el resto, en el menú.", font=font(SANS, 18), fill=MUTED) # el movil esquematico px0, py0, px1, py1 = 110, 170, 470, 790 d.rounded_rectangle((px0, py0, px1, py1), 30, outline=FG, width=5) d.rounded_rectangle((px0 + 16, py0 + 20, px1 - 16, py0 + 84), 14, fill=BG, outline=FG, width=3) s = font(SANS, 15) d.text((px0 + 46, py0 + 42), "logo · no molestar · avatar", font=s, fill=FG) # en medio, la actividad: lo que ha pasado en la red y = py0 + 110 d.text((px0 + 40, y), "ACTIVIDAD", font=font(ANTON, 22), fill=FG) d.line((px0 + 40, y + 32, px1 - 40, y + 32), fill=FG, width=3) y += 52 for i in range(4): d.rounded_rectangle((px0 + 40, y, px1 - 40, y + 86), 12, fill=BG, outline=FG, width=3) d.rectangle((px0 + 56, y + 18, px0 + 150, y + 30), fill=FG) d.rectangle((px0 + 56, y + 44, px1 - 70, y + 52), fill=MUTED) d.rectangle((px0 + 56, y + 62, px1 - 120, y + 70), fill=MUTED) y += 104 d.rounded_rectangle((px0 + 16, py1 - 84, px1 - 16, py1 - 20), 14, fill=NEON, outline=FG, width=3) d.text((px0 + 62, py1 - 62), "4 atajos que eliges tú · MENÚ", font=s, fill=FG) bx0, bx1 = 560, 1320 cajas = [ ((bx0, 190, bx1, 340), "ARRIBA — LA BARRA SUPERIOR", ["El logo vuelve al inicio y el avatar abre tu perfil.", "En medio, el botón de No molestar del Teléfono.", "Con muchos módulos activos salen también los filtros Personal y Social."], BG), ((bx0, 375, bx1, 565), "EN MEDIO — LA ACTIVIDAD", ["Al abrir la app se ve lo que ha pasado en la red, no un menú.", "Dentro de cada módulo, los filtros van en fichas pequeñas", "y el botón principal (crear tribu, crear sala…) siempre igual."], BG), ((bx0, 600, bx1, 750), "ABAJO — LOS ATAJOS Y EL MENÚ", ["Cuatro atajos a tu elección y una quinta pestaña fija, Menú,", "que abre desde abajo una hoja con todos los módulos por secciones", "(Comunidad, Biblioteca, Gobernanza…). Está en todas las pantallas."], NEON), ] flechas = [((px1 + 10, py0 + 52), (bx0 - 12, 262)), ((px1 + 10, 440), (bx0 - 12, 470)), ((px1 + 10, py1 - 52), (bx0 - 12, 675))] for (a, b) in flechas: ang = math.atan2(b[1] - a[1], b[0] - a[0]) tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da)) d.line((a, tip(0, 12)), fill=FG, width=4) d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG) for box, t, l, r in cajas: caja(d, box, t, l, r) out.save(HERE / "esquema-interfaz.png", optimize=True) def flecha(d, a, b, ancho=4): ang = math.atan2(b[1] - a[1], b[0] - a[0]) tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da)) d.line((a, tip(0, 12)), fill=FG, width=ancho) d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG) def titulo(d, texto, sub): d.text((46, 26), texto, font=font(ANTON, 46), fill=FG) bottom = d.textbbox((46, 26), texto, font=font(ANTON, 46))[3] d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG) d.text((46, bottom + 30), sub, font=font(SANS, 18), fill=MUTED) def esquema_rama(): """Como va la rama encima de Oasis: lo del dev abajo, lo nuestro encima.""" W, H = 1400, 760 out = Image.new("RGB", (W, H), BG) d = ImageDraw.Draw(out) titulo(d, "CÓMO VA LA RAMA ENCIMA DE OASIS", "Oasis lo cambia el dev, casi una versión al día. La rama solo cambia cómo se ve y se usa en el móvil.") capa = [ ((60, 210, 380, 400), "ASPECTO", ["mobile_base.css: la maqueta", "mobile_ux.css: márgenes, letra,", "botones y piezas nuevas", "Acampada.css: el skin"], NEON), ((400, 210, 720, 400), "PANTALLAS", ["mobile_ui.js y las vistas", "mobile_*.js. En una vista", "del dev, como mucho una", "línea que las llama"], NEON), ((740, 210, 1060, 400), "RUTAS", ["fork_routes.js, delante", "del router del dev: lo", "que no atiende pasa al", "suyo"], NEON), ((1080, 210, 1340, 400), "ANDROID", ["la app del dev tal cual,", "con nuestro backend", "dentro y paquete", "propio"], NEON), ] for box, t, l, r in capa: caja(d, box, t, l, r) d.text((60, 166), "LO DE LA RAMA", font=font(ANTON, 26), fill=FG) for x in (220, 560, 900, 1210): flecha(d, (x, 418), (x, 478)) caja(d, (60, 500, 1340, 690), "OASIS DEL DEV: NO SE TOCA", ["backend.js y su middleware · los modelos (lo que se publica y cómo se cuenta) · SSB y su arranque", "las traducciones · style.css y mobile.css · los temas, también OasisMobile.css", "Comparado con su APK oficial, la rama cambia 17 ficheros suyos y 561 líneas. Ver GUIA_DE_DESARROLLO.md"], BG) out.save(HERE / "esquema-rama.png", optimize=True) def esquema_llamadas(): """Las llamadas son las del dev; en Android pasan por un puente nativo de la app.""" W, H = 1400, 700 out = Image.new("RGB", (W, H), BG) d = ImageDraw.Draw(out) titulo(d, "LLAMADAS: LAS DEL DEV", "Teléfono y Salas son de Oasis. En Android el sonido y la cámara pasan por un puente nativo de la app.") cajas = [ ((50, 200, 360, 430), "TELÉFONO Y SALAS", ["De Oasis, tal cual.", "Llamadas de uno a uno", "con buzón de voz; salas", "de hasta 50, con vídeo,", "grabación y emisión."], BG), ((400, 200, 700, 430), "LOS ENCHUFES", ["phone_*_android.js", "(del dev): se conectan", "a un puerto local que", "deja la app en", ".audio_bridge"], BG), ((740, 200, 1040, 430), "EL PUENTE NATIVO", ["Dentro de la app:", "R graba el micro", "P reproduce", "V saca la cámara", "S emite un fichero"], NEON), ((1080, 200, 1350, 430), "EL MÓVIL", ["Micrófono, cámara", "y altavoz, con los", "permisos que se piden", "la primera vez."], BG), ] for box, t, l, r in cajas: caja(d, box, t, l, r) for a, b in (((362, 315), (398, 315)), ((702, 315), (738, 315)), ((1042, 315), (1078, 315))): flecha(d, a, b) caja(d, (50, 480, 1350, 630), "DÓNDE ESTÁ CADA COSA", ["El puente está en la app Android del dev, que no tiene el código publicado. Por eso la APK de la", "rama se monta sobre la oficial: su app tal cual, nuestro backend dentro y paquete propio."], BG) out.save(HERE / "esquema-llamadas.png", optimize=True) if __name__ == "__main__": portada() esquema() esquema_rama() esquema_llamadas()