Mapping - Los clips salian del reves: mapper.js activaba UNPACK_FLIP_Y_WEBGL, pero el modelo va con origen arriba-izquierda y texImage2D ya sube la primera fila en t=0, asi que el flip la mandaba al final. Comprobado renderizando el mapper real en Chromium headless con una fuente mitad roja / mitad azul. - projectM se puede usar YA en una capa: los .milk de data/presets-projectm se traducen a Butterchurn en el navegador al elegirlos (milkdrop-preset- converter). La capa MilkDrop gana 'biblioteca' (butter | projectm) sin cambiar su firma, para no recrear el contexto WebGL al cambiar de una a otra. Medido: 100/100 presets de una muestra convierten, 84/84 de los que llevan shaders warp/comp; ~7 ms por preset. - Cada capa tiene su pestana arriba y su propia vista, y '+ CAPA' crea una y entra en ella: con varias capas, ir y volver a MAPPING no era viable. - Dos capas MilkDrop sin preset ya no salen identicas (cogian el indice 0): cada instancia elige uno al azar y lo escribe en el estado. - Una capa nueva ya no nace en la fuente "motor", que es el lienzo del motor activo y hacia que dos capas ensenaran lo mismo. Galeria de visuales - scripts/catalogar-visuales.py: ficha de cada clip (pelicula, personajes, duracion) y, sobre todo, si es una silueta de verdad y cuanta figura tiene. Distingue silueta de corte crudo por el negro puro del fondo: 0,63-0,90 frente a 0,04-0,06, sin zona gris. - El panel lista los clips agrupados por pelicula, con nombre legible y aviso de los que casi no tienen figura, en vez del nombre del archivo. - Soporte de siluetas con canal alfa (.webm VP9): transparencia de verdad, sin recorte por luminancia. El shader del mapper ya la respeta. Documentacion - docs/visuales.md nuevo; pendiente.md al dia con lo hecho y lo que queda. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1081 lines
41 KiB
Python
1081 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
FOSFENO - Motor de visuales audio-reactivas para Raspberry Pi OS.
|
|
|
|
Un unico proceso que:
|
|
- sirve el PANEL de control (http://<ip>/)
|
|
- sirve el ESCENARIO (http://<ip>/stage) -> lo abre Chromium en kiosko
|
|
- lanza/cierra Chromium y el visualizador nativo projectM
|
|
- mantiene el estado y lo sincroniza con panel y escenario via WebSocket
|
|
|
|
Motores: projectm (nativo), butterchurn, hydra, shaders (GLSL) y mixer
|
|
(mezclador VJ basado en Hydra: camara + video + efectos).
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, request, send_from_directory
|
|
from flask_socketio import SocketIO
|
|
from werkzeug.utils import secure_filename
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Rutas y configuracion
|
|
# --------------------------------------------------------------------------
|
|
BASE = Path(__file__).resolve().parent.parent # .../FOSFENO
|
|
WEB = BASE / "web"
|
|
DATA = BASE / "data"
|
|
VIDEOS = DATA / "videos"
|
|
VIDEO_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")
|
|
IMAGE_EXT = (".jpg", ".jpeg", ".png", ".gif", ".webp")
|
|
MEDIA_EXT = VIDEO_EXT + IMAGE_EXT # la galeria del mezclador admite ambos
|
|
|
|
|
|
def load_json(path, default):
|
|
try:
|
|
with open(path, encoding="utf-8") as fh:
|
|
return json.load(fh)
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
print(f"[FOSFENO] No se pudo leer {path}: {exc}")
|
|
return default
|
|
|
|
|
|
CFG = load_json(BASE / "config.json", {})
|
|
HOST = CFG.get("server", {}).get("host", "0.0.0.0")
|
|
# El puerto y el modo kiosko se pueden forzar por variable de entorno; asi el
|
|
# modo portatil (script 'fosfeno') usa el puerto 8080 sin tocar config.json.
|
|
PORT = int(os.environ.get("FOSFENO_PORT") or CFG.get("server", {}).get("port", 80))
|
|
NO_KIOSK = bool(os.environ.get("FOSFENO_NO_KIOSK"))
|
|
# Clave compartida para la red local. Vacia (por defecto) = abierto, como
|
|
# siempre: en casa o en un ensayo no molesta. Si se pone (config.json ->
|
|
# auth.token, o la variable FOSFENO_TOKEN), hace falta para conectarse al
|
|
# panel y para subir archivos, y el codigo QR del proyector ya la lleva: el
|
|
# que escanea entra, el que solo conoce la IP no. Recomendado en local
|
|
# publico, donde cualquiera en la wifi podria apagar el equipo o meter
|
|
# codigo en las visuales.
|
|
TOKEN = os.environ.get("FOSFENO_TOKEN") or CFG.get("auth", {}).get("token", "")
|
|
|
|
app = Flask(__name__, static_folder=None)
|
|
# Tope por archivo subido. Con 4 GB, cualquiera en la red podia llenar la
|
|
# tarjeta de la Raspberry en tres subidas; con esto siguen cabiendo clips
|
|
# largos de sobra.
|
|
app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 ** 2 # 512 MB
|
|
# cors_allowed_origins=None => solo el mismo origen, calculado del Host de
|
|
# cada peticion (asi vale igual por IP, por fosfeno.local o por localhost).
|
|
# Con el comodin "*", una web cualquiera abierta en un movil de la sala podia
|
|
# conectarse al socket y mandar ordenes.
|
|
socketio = SocketIO(app, async_mode="threading", cors_allowed_origins=None)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Datos cargados de disco
|
|
# --------------------------------------------------------------------------
|
|
HYDRA_SKETCHES = load_json(DATA / "hydra-sketches.json", {})
|
|
SHADERS = load_json(DATA / "shaders.json", {})
|
|
|
|
DEFAULTS = CFG.get("defaults", {})
|
|
ENGINES = ("projectm", "butterchurn", "hydra", "shaders", "mixer")
|
|
|
|
|
|
def first_code(table, key, field="code"):
|
|
"""Devuelve el codigo de una entrada de un diccionario de presets."""
|
|
entry = table.get(key) or next(iter(table.values()), {})
|
|
return entry.get(field, "") if isinstance(entry, dict) else ""
|
|
|
|
|
|
def list_videos():
|
|
if not VIDEOS.is_dir():
|
|
return []
|
|
return sorted(f.name for f in VIDEOS.iterdir()
|
|
if f.suffix.lower() in MEDIA_EXT)
|
|
|
|
|
|
def load_visuales():
|
|
"""Fichas de la galeria: de que peli sale cada clip, quien aparece y como
|
|
de llena esta la silueta. Lo genera scripts/catalogar-visuales.py; si no
|
|
esta, el panel sigue funcionando con los nombres de archivo pelados.
|
|
Devuelve {archivo: ficha} y solo de lo que sigue en disco, para que un
|
|
catalogo viejo no ensene clips borrados."""
|
|
ruta = DATA / "visuales.json"
|
|
datos = load_json(ruta, {}) if ruta.is_file() else {}
|
|
hay = set(list_videos())
|
|
return {c["archivo"]: c for c in datos.get("clips", [])
|
|
if isinstance(c, dict) and c.get("archivo") in hay}
|
|
|
|
|
|
def list_projectm_presets():
|
|
"""Categorias y visuales de data/presets-projectm. El pack se organiza
|
|
como categoria/visual/variante.milk: cada 'visual' (Aurora, Blobby...)
|
|
es una carpeta con variantes del mismo estilo. Devuelve
|
|
{categoria: [visuales]}. Las carpetas '!...' se saltan (transiciones)."""
|
|
base = DATA / "presets-projectm"
|
|
cats = {}
|
|
if not base.is_dir():
|
|
return cats
|
|
for d in sorted(base.iterdir()):
|
|
if not d.is_dir() or d.name.startswith(("!", ".")):
|
|
continue
|
|
visuals = sorted(
|
|
v.name for v in d.iterdir()
|
|
if v.is_dir() and any(f.suffix.lower() == ".milk"
|
|
for f in v.iterdir()))
|
|
if visuals:
|
|
cats[d.name] = visuals
|
|
return cats
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Estado global (se difunde a todos los clientes)
|
|
# --------------------------------------------------------------------------
|
|
NOTIFY_HISTORY = [] # ultimos avisos mostrados en el panel
|
|
|
|
state = {
|
|
"engine": DEFAULTS.get("engine", "butterchurn"),
|
|
"power": True,
|
|
"sensitivity": float(DEFAULTS.get("sensitivity", 1.0)),
|
|
# source: "mic" (microfono de la sala) o "monitor" (lo que suena en el
|
|
# propio equipo: navegador, Spotify... captado por el monitor del sistema).
|
|
"audio": {"device": "", "bpm": 0, "source": "mic"},
|
|
"butterchurn": {
|
|
"preset": "", "shuffle": True,
|
|
"interval": 20, "intervalMode": "seconds", "blendTime": 2.7,
|
|
},
|
|
"hydra": {
|
|
"label": "",
|
|
"code": first_code(HYDRA_SKETCHES, DEFAULTS.get("hydraSketch", "")),
|
|
},
|
|
"shaders": {
|
|
"label": "",
|
|
"code": first_code(SHADERS, DEFAULTS.get("shader", "")),
|
|
},
|
|
"mixer": {
|
|
# fondo: capa de abajo de la mezcla ("cam" o "butter" = MilkDrop)
|
|
"source": "cam", "fondo": "cam", "camOn": True, "cameraId": 0,
|
|
"video": "", "mix": 0.5, "blendMode": "blend",
|
|
"hue": 0.0, "saturate": 1.0, "contrast": 1.0, "brightness": 0.0,
|
|
"colorama": 0.0, "posterize": 0, "pixelate": 0, "kaleid": 0,
|
|
"rotate": 0.0, "feedback": 0.0, "invert": False, "beatPulse": False,
|
|
},
|
|
# projectM: categoria (subcarpeta de presets), preset concreto (fijo),
|
|
# segundos por visual y orden aleatorio. Cambiarlos relanza projectM.
|
|
# locked: visual congelado con la tecla de bloqueo (se pierde al relanzar).
|
|
# nowPlaying/nowCategory/nowVisual: lo que esta en pantalla ahora mismo,
|
|
# leido del log de projectM (~/.config/projectM/projectMSDL.log).
|
|
# monitor: "auto" (la pantalla del escenario) o el numero de monitor.
|
|
# variante: un .milk exacto elegido a dedo ("" = rota dentro del visual).
|
|
# favoritos: visuales guardados para pincharlos en directo (persisten
|
|
# en data/pm-favoritos.json).
|
|
"projectm": {"available": False, "category": "", "preset": "",
|
|
"duration": 60, "shuffle": True, "locked": False,
|
|
"nowPlaying": "", "nowCategory": "", "nowVisual": "",
|
|
"monitor": "auto", "variante": "",
|
|
"favoritos": load_json(DATA / "pm-favoritos.json", [])},
|
|
# mapping: projection mapping in-browser. enabled = deformar la salida;
|
|
# edit = mostrar los tiradores en el escenario para ajustarlo con el raton;
|
|
# surfaces = [{id, corners:[[x,y]x4]}] normalizado 0..1. Persiste en
|
|
# data/mapping.json.
|
|
"mapping": dict(load_json(DATA / "mapping.json",
|
|
{"enabled": False, "surfaces": [], "masks": []}),
|
|
# 'edit' (los tiradores encima de las propias visuales) es
|
|
# un modo de montaje, no un ajuste: arranca siempre
|
|
# apagado para que el proyector salga limpio, sin marcos
|
|
# de colores ni circulos en las esquinas.
|
|
edit=False),
|
|
"meta": {
|
|
"butterchurnPresets": [],
|
|
"audioDevices": [],
|
|
"cameraDevices": [],
|
|
"videos": list_videos(),
|
|
"visuales": load_visuales(),
|
|
"projectmPresets": {},
|
|
"monitors": [],
|
|
},
|
|
"status": {"label": ""},
|
|
"notifications": NOTIFY_HISTORY,
|
|
"network": {"ip": "", "hostname": "", "port": PORT, "panelSeen": False},
|
|
}
|
|
lock = threading.Lock()
|
|
|
|
|
|
def broadcast():
|
|
socketio.emit("state", state)
|
|
|
|
|
|
def notify(level, message):
|
|
"""Registra un aviso y lo envia al panel.
|
|
|
|
level puede ser 'info', 'warn' o 'error'. Asi el usuario nunca se queda
|
|
sin saber que ha pasado: cualquier fallo aparece en la parte de arriba
|
|
del panel de control.
|
|
"""
|
|
entry = {"level": level, "message": str(message), "t": int(time.time())}
|
|
print(f"[FOSFENO] aviso({level}): {message}")
|
|
NOTIFY_HISTORY.append(entry)
|
|
while len(NOTIFY_HISTORY) > 20:
|
|
NOTIFY_HISTORY.pop(0)
|
|
try:
|
|
socketio.emit("notify", entry)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def get_lan_ip():
|
|
"""Devuelve la IP de la Raspberry en la red local."""
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
sock.connect(("8.8.8.8", 80))
|
|
return sock.getsockname()[0]
|
|
except OSError:
|
|
return "127.0.0.1"
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def refresh_network():
|
|
"""Actualiza la IP y el nombre de red de la Raspberry. Devuelve True si la
|
|
IP ha cambiado, para avisar al panel y al escenario."""
|
|
new_ip = get_lan_ip()
|
|
changed = state["network"]["ip"] != new_ip
|
|
state["network"]["ip"] = new_ip
|
|
state["network"]["hostname"] = socket.gethostname()
|
|
return changed
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Gestion de procesos (Chromium kiosko y projectM nativo)
|
|
# --------------------------------------------------------------------------
|
|
procs = {"chromium": None, "projectm": None}
|
|
|
|
|
|
def is_running(proc):
|
|
return proc is not None and proc.poll() is None
|
|
|
|
|
|
def start_chromium():
|
|
"""Abre el escenario en Chromium a pantalla completa."""
|
|
if is_running(procs["chromium"]):
|
|
return
|
|
kiosk = CFG.get("kiosk", {})
|
|
browser = shutil.which(kiosk.get("browser", "chromium-browser")) \
|
|
or shutil.which("chromium")
|
|
if not browser:
|
|
notify("error", "No se encontro Chromium. Las visuales web no pueden "
|
|
"mostrarse. Ejecuta install.sh para instalarlo.")
|
|
return
|
|
# El escenario tambien necesita la clave para hablar con el servidor
|
|
url = f"http://localhost:{PORT}/stage" + (f"?k={TOKEN}" if TOKEN else "")
|
|
procs["chromium"] = subprocess.Popen([
|
|
browser,
|
|
"--kiosk", f"--app={url}",
|
|
"--use-fake-ui-for-media-stream", # acepta micro y camara sin preguntar
|
|
"--autoplay-policy=no-user-gesture-required",
|
|
"--noerrdialogs", "--disable-infobars", "--disable-translate",
|
|
"--disable-features=Translate",
|
|
"--check-for-update-interval=31536000",
|
|
"--ozone-platform-hint=auto",
|
|
])
|
|
print("[FOSFENO] Chromium abierto en", url)
|
|
|
|
|
|
def stop_projectm():
|
|
proc = procs["projectm"]
|
|
if is_running(proc):
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
procs["projectm"] = None
|
|
|
|
|
|
def save_favoritos():
|
|
"""Persiste los visuales favoritos de projectM en data/pm-favoritos.json."""
|
|
try:
|
|
with open(DATA / "pm-favoritos.json", "w", encoding="utf-8") as fh:
|
|
json.dump(state["projectm"]["favoritos"], fh,
|
|
ensure_ascii=False, indent=2)
|
|
except OSError as exc:
|
|
notify("warn", f"No pude guardar los favoritos: {exc}")
|
|
|
|
|
|
def list_monitors():
|
|
"""Monitores conectados segun xrandr, para el selector del panel.
|
|
Devuelve [{'id': 1, 'name': 'eDP-1 (1920x1080)'}, ...]; el id va en el
|
|
formato de projectMSDL (1 = primer monitor)."""
|
|
if not shutil.which("xrandr"):
|
|
return []
|
|
mons = []
|
|
try:
|
|
out = subprocess.run(
|
|
["xrandr", "--listmonitors"], capture_output=True, text=True,
|
|
timeout=3, check=False).stdout
|
|
for line in out.splitlines():
|
|
m = re.search(r"^\s*(\d+):\s+\+?\*?(\S+)\s+(\d+)/\d+x(\d+)/\d+",
|
|
line)
|
|
if m:
|
|
idx, name, w, h = m.groups()
|
|
mons.append({"id": int(idx) + 1,
|
|
"name": f"{name} ({w}x{h})"})
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return mons
|
|
|
|
|
|
def monitor_of_stage():
|
|
"""Numero de monitor (formato projectMSDL: 1 = primero) donde esta la
|
|
ventana del escenario. Con varias pantallas, projectM debe abrirse
|
|
siempre encima del escenario (el proyector) y no donde este el raton.
|
|
Devuelve 0 (posicion por defecto del SO) si no se puede averiguar."""
|
|
if not shutil.which("xdotool") or not shutil.which("xrandr"):
|
|
return 0
|
|
try:
|
|
geo = subprocess.run(
|
|
["xdotool", "search", "--name", "FOSFENO :: Escenario",
|
|
"getwindowgeometry"], capture_output=True, text=True,
|
|
timeout=3, check=False).stdout
|
|
pos = re.search(r"Position: (-?\d+),(-?\d+)", geo)
|
|
if not pos:
|
|
return 0
|
|
wx, wy = int(pos.group(1)), int(pos.group(2))
|
|
mons = subprocess.run(
|
|
["xrandr", "--listmonitors"], capture_output=True, text=True,
|
|
timeout=3, check=False).stdout
|
|
for line in mons.splitlines():
|
|
m = re.search(
|
|
r"^\s*(\d+):\s+\S+\s+(\d+)/\d+x(\d+)/\d+([+-]\d+)([+-]\d+)",
|
|
line)
|
|
if not m:
|
|
continue
|
|
idx, w, h, x, y = (int(v) for v in m.groups())
|
|
if x <= wx < x + w and y <= wy < y + h:
|
|
return idx + 1
|
|
except (OSError, subprocess.SubprocessError, ValueError):
|
|
pass
|
|
return 0
|
|
|
|
|
|
def dentro_de(base, *partes):
|
|
"""Ruta compuesta solo si no se sale de 'base'. Categoria, visual y
|
|
variante llegan del panel, y con '../..' se podria apuntar a cualquier
|
|
carpeta del disco."""
|
|
base = Path(base).resolve()
|
|
try:
|
|
p = base.joinpath(*partes).resolve()
|
|
except (OSError, ValueError):
|
|
return None
|
|
return p if str(p).startswith(str(base) + os.sep) else None
|
|
|
|
|
|
def start_projectm():
|
|
"""Lanza projectM nativo con los ajustes del panel (categoria, preset
|
|
fijo, duracion, aleatorio); su ventana se superpone a Chromium."""
|
|
stop_projectm()
|
|
script = BASE / "scripts" / "start-projectm.sh"
|
|
pm = state["projectm"]
|
|
base = DATA / "presets-projectm"
|
|
path = base
|
|
if pm.get("category"):
|
|
cat = dentro_de(base, pm["category"])
|
|
if cat and cat.is_dir():
|
|
path = cat
|
|
try:
|
|
duration = max(2, int(pm.get("duration") or 20))
|
|
except (TypeError, ValueError):
|
|
duration = 20
|
|
shuffle = "1" if pm.get("shuffle", True) else "0"
|
|
# Visual concreto elegido: cada visual es una carpeta con variantes del
|
|
# mismo estilo -> projectM rota solo dentro de ese look.
|
|
if pm.get("preset") and pm.get("category"):
|
|
vis = dentro_de(base, pm["category"], pm["preset"])
|
|
if vis and vis.is_dir():
|
|
path = vis
|
|
# Variante exacta elegida a dedo: projectM solo entiende carpetas, asi
|
|
# que se le prepara una carpeta con esa unica variante dentro.
|
|
if pm.get("variante") and pm.get("category") and pm.get("preset"):
|
|
origen = dentro_de(base, pm["category"], pm["preset"],
|
|
pm["variante"] + ".milk")
|
|
if origen and origen.is_file():
|
|
fijado = DATA / "pm-fijado"
|
|
shutil.rmtree(fijado, ignore_errors=True)
|
|
fijado.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy(origen, fijado / origen.name)
|
|
path = fijado
|
|
# Pantalla elegida en el panel: "auto" sigue al escenario; un numero
|
|
# la fija aunque el escenario este en otra (o no exista la ventana).
|
|
try:
|
|
mon = int(pm.get("monitor"))
|
|
except (TypeError, ValueError):
|
|
mon = 0
|
|
if mon <= 0:
|
|
mon = monitor_of_stage()
|
|
env = dict(os.environ,
|
|
FOSFENO_PM_PATH=str(path),
|
|
FOSFENO_PM_DURATION=str(duration),
|
|
FOSFENO_PM_SHUFFLE=shuffle,
|
|
FOSFENO_PM_MONITOR=str(mon))
|
|
# El bloqueo con tecla no sobrevive al relanzamiento del proceso
|
|
pm["locked"] = False
|
|
try:
|
|
procs["projectm"] = subprocess.Popen(["bash", str(script)], env=env)
|
|
print(f"[FOSFENO] projectM lanzado (presets: {path.name}, "
|
|
f"{duration}s, shuffle={shuffle}).")
|
|
except OSError as exc:
|
|
notify("error", f"No se pudo lanzar projectM: {exc}. "
|
|
"Prueba con otro motor de visuales.")
|
|
return
|
|
# projectM (SDL) abre su captura en la entrada por defecto; si toca
|
|
# "audio del sistema", mover su stream en cuanto aparezca en pactl.
|
|
# En la Raspberry projectM tarda varios segundos en arrancar: se le da
|
|
# margen de sobra (2s + 30 intentos x 0.3s = ~11s).
|
|
if state["audio"]["source"] == "monitor":
|
|
threading.Timer(
|
|
2.0, lambda: route_captures("monitor", quiet=True,
|
|
attempts=30)).start()
|
|
|
|
|
|
def projectm_key(key):
|
|
"""Envia una tecla a projectM (solo funciona en sesion X11, no Wayland)."""
|
|
if not shutil.which("xdotool"):
|
|
return
|
|
subprocess.run(
|
|
["xdotool", "search", "--name", "projectM",
|
|
"key", "--clearmodifiers", key],
|
|
timeout=3, check=False,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
|
|
# projectM escribe en su log cada visual que muestra, con la ruta completa:
|
|
# es la forma fiable de saber que hay en pantalla en cada momento.
|
|
PM_LOG = Path.home() / ".config" / "projectM" / "projectMSDL.log"
|
|
|
|
|
|
def projectm_now_playing():
|
|
"""Ultimo 'Displaying preset' del log -> (categoria, visual, variante)."""
|
|
try:
|
|
with open(PM_LOG, "rb") as fh:
|
|
fh.seek(0, os.SEEK_END)
|
|
fh.seek(max(0, fh.tell() - 8192))
|
|
tail = fh.read().decode("utf-8", "replace")
|
|
except OSError:
|
|
return "", "", ""
|
|
ruta = ""
|
|
for line in tail.splitlines():
|
|
if "Displaying preset:" in line:
|
|
ruta = line.split("Displaying preset:", 1)[1].strip()
|
|
if not ruta:
|
|
return "", "", ""
|
|
try:
|
|
cat, visual, variante = Path(ruta).relative_to(
|
|
DATA / "presets-projectm").parts
|
|
except ValueError:
|
|
return "", "", Path(ruta).stem # preset fuera del catalogo propio
|
|
return cat, visual, Path(variante).stem
|
|
|
|
|
|
def watch_projectm_log():
|
|
"""Bucle de fondo: refleja en el estado el visual que projectM muestra,
|
|
para que el panel ensene su nombre y pueda fijarlo con un boton."""
|
|
while True:
|
|
time.sleep(2)
|
|
if not (state["engine"] == "projectm" and state["power"]
|
|
and is_running(procs["projectm"])):
|
|
continue
|
|
cat, visual, variante = projectm_now_playing()
|
|
if not variante or variante == state["projectm"]["nowPlaying"]:
|
|
continue
|
|
with lock:
|
|
state["projectm"]["nowPlaying"] = variante
|
|
state["projectm"]["nowCategory"] = cat
|
|
state["projectm"]["nowVisual"] = visual
|
|
state["status"]["label"] = variante
|
|
broadcast()
|
|
|
|
|
|
def apply_engine():
|
|
"""Arranca o detiene projectM segun el motor y el estado de encendido."""
|
|
use_native = state["engine"] == "projectm" and state["power"]
|
|
if state["engine"] == "projectm" and not state["projectm"]["available"]:
|
|
notify("warn", "projectM no esta instalado en esta Raspberry. "
|
|
"Elige otro motor o vuelve a compilarlo con "
|
|
"scripts/build-projectm.sh.")
|
|
if use_native and state["projectm"]["available"]:
|
|
start_projectm()
|
|
else:
|
|
stop_projectm()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Rutas HTTP (panel, escenario, librerias, datos)
|
|
# --------------------------------------------------------------------------
|
|
@app.route("/")
|
|
def panel_index():
|
|
return send_from_directory(str(WEB / "panel"), "index.html")
|
|
|
|
|
|
@app.route("/panel/<path:fname>")
|
|
def panel_files(fname):
|
|
return send_from_directory(str(WEB / "panel"), fname)
|
|
|
|
|
|
@app.route("/stage")
|
|
def stage_index():
|
|
return send_from_directory(str(WEB / "stage"), "index.html")
|
|
|
|
|
|
@app.route("/stage/<path:fname>")
|
|
def stage_files(fname):
|
|
return send_from_directory(str(WEB / "stage"), fname)
|
|
|
|
|
|
@app.route("/lib/<path:fname>")
|
|
def lib_files(fname):
|
|
return send_from_directory(str(WEB / "lib"), fname)
|
|
|
|
|
|
@app.route("/data/<path:fname>")
|
|
def data_files(fname):
|
|
return send_from_directory(str(DATA), fname)
|
|
|
|
|
|
@app.route("/pm/variantes")
|
|
def pm_variantes():
|
|
"""Lista las variantes (.milk) de un visual, para el selector del panel."""
|
|
base = (DATA / "presets-projectm").resolve()
|
|
d = (base / request.args.get("categoria", "")
|
|
/ request.args.get("visual", "")).resolve()
|
|
if not str(d).startswith(str(base) + os.sep) or not d.is_dir():
|
|
return {"variantes": []}
|
|
return {"variantes": sorted(
|
|
f.stem for f in d.iterdir() if f.suffix.lower() == ".milk")}
|
|
|
|
|
|
@app.route("/upload", methods=["POST"])
|
|
def upload_media():
|
|
"""Sube un video o una imagen a la galeria del mezclador (data/videos)."""
|
|
# Con clave puesta, subir tambien la pide: si no, cualquiera en la wifi
|
|
# puede llenar la tarjeta de la Raspberry.
|
|
if TOKEN and request.args.get("k") != TOKEN:
|
|
return {"ok": False, "error": "No autorizado."}, 403
|
|
f = request.files.get("file")
|
|
if not f or not f.filename:
|
|
return {"ok": False, "error": "No llego ningun archivo."}, 400
|
|
name = secure_filename(f.filename)
|
|
if not name or not name.lower().endswith(MEDIA_EXT):
|
|
return {"ok": False,
|
|
"error": "Formato no valido. Sube video (mp4, webm, mov, "
|
|
"m4v, ogv) o imagen (jpg, png, gif, webp)."}, 400
|
|
try:
|
|
VIDEOS.mkdir(parents=True, exist_ok=True)
|
|
f.save(str(VIDEOS / name))
|
|
except OSError as exc:
|
|
notify("error", f"No se pudo guardar '{name}': {exc}")
|
|
return {"ok": False, "error": f"No se pudo guardar: {exc}"}, 500
|
|
with lock:
|
|
state["meta"]["videos"] = list_videos()
|
|
state["meta"]["visuales"] = load_visuales()
|
|
notify("info", f"'{name}' anadido a la galeria del mezclador.")
|
|
broadcast()
|
|
return {"ok": True, "name": name}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Eventos WebSocket
|
|
# --------------------------------------------------------------------------
|
|
@socketio.on("connect")
|
|
def on_connect(auth=None):
|
|
# Sin la clave no se conecta: ni ordenes, ni codigo en las visuales, ni
|
|
# apagar la maquina. Con TOKEN vacio entra todo el mundo, como siempre.
|
|
if TOKEN and (auth or {}).get("token") != TOKEN:
|
|
return False
|
|
socketio.emit("state", state)
|
|
return None
|
|
|
|
|
|
@socketio.on("hello")
|
|
def on_hello(data):
|
|
"""El panel se presenta al conectarse. Asi el escenario sabe que alguien
|
|
ya ha encontrado el panel y puede dejar de mostrar el codigo QR."""
|
|
if (data or {}).get("role") == "panel" and not state["network"]["panelSeen"]:
|
|
with lock:
|
|
state["network"]["panelSeen"] = True
|
|
broadcast()
|
|
|
|
|
|
@socketio.on("set_engine")
|
|
def on_set_engine(data):
|
|
engine = (data or {}).get("engine")
|
|
if engine not in ENGINES:
|
|
return
|
|
with lock:
|
|
state["engine"] = engine
|
|
apply_engine()
|
|
broadcast()
|
|
|
|
|
|
@socketio.on("set_power")
|
|
def on_set_power(data):
|
|
with lock:
|
|
state["power"] = bool((data or {}).get("on"))
|
|
apply_engine()
|
|
broadcast()
|
|
|
|
|
|
@socketio.on("set_sensitivity")
|
|
def on_set_sensitivity(data):
|
|
try:
|
|
value = float((data or {}).get("value", 1.0))
|
|
except (TypeError, ValueError):
|
|
return
|
|
with lock:
|
|
state["sensitivity"] = max(0.0, min(4.0, value))
|
|
broadcast()
|
|
|
|
|
|
@socketio.on("run_code")
|
|
def on_run_code(data):
|
|
"""Ejecuta codigo (Hydra o GLSL) escrito/pegado o un fragmento de la libreria."""
|
|
engine = (data or {}).get("engine")
|
|
code = (data or {}).get("code")
|
|
label = (data or {}).get("label", "codigo personalizado")
|
|
if engine in ("hydra", "shaders") and isinstance(code, str):
|
|
with lock:
|
|
state[engine]["code"] = code
|
|
state[engine]["label"] = label
|
|
state["status"]["label"] = label
|
|
broadcast()
|
|
|
|
|
|
# Claves que cada motor acepta en update_settings. Sin esta lista, cualquiera
|
|
# en la red puede meter claves inventadas o cadenas kilometricas en el estado,
|
|
# que ademas se difunde entero a todos los clientes en cada cambio.
|
|
CLAVES_AJUSTES = {
|
|
"butterchurn": {"preset", "shuffle", "interval", "intervalMode", "blendTime"},
|
|
"mixer": {"source", "fondo", "camOn", "cameraId", "video", "mix",
|
|
"blendMode", "hue", "saturate", "contrast", "brightness",
|
|
"colorama", "posterize", "pixelate", "kaleid", "rotate",
|
|
"feedback", "invert", "beatPulse"},
|
|
"audio": {"device", "bpm", "source"},
|
|
"projectm": {"category", "preset", "duration", "shuffle", "monitor",
|
|
"variante"},
|
|
}
|
|
|
|
|
|
def patch_saneado(engine, patch):
|
|
"""Deja solo claves conocidas con valores simples y de tamano razonable."""
|
|
permitidas = CLAVES_AJUSTES.get(engine, set())
|
|
limpio = {}
|
|
for clave, valor in patch.items():
|
|
if clave not in permitidas:
|
|
continue
|
|
if isinstance(valor, str) and len(valor) > 512:
|
|
continue
|
|
if isinstance(valor, (str, int, float, bool)):
|
|
limpio[clave] = valor
|
|
return limpio
|
|
|
|
|
|
@socketio.on("update_settings")
|
|
def on_update_settings(data):
|
|
"""Actualiza ajustes de un motor: butterchurn, mixer, audio o projectm."""
|
|
engine = (data or {}).get("engine")
|
|
patch = (data or {}).get("patch", {})
|
|
if engine in ("butterchurn", "mixer", "audio", "projectm") \
|
|
and isinstance(patch, dict):
|
|
patch = patch_saneado(engine, patch)
|
|
with lock:
|
|
state[engine].update(patch)
|
|
# Los ajustes de projectM se aplican relanzandolo (tarda ~1s)
|
|
if engine == "projectm" and state["engine"] == "projectm" \
|
|
and state["power"]:
|
|
start_projectm()
|
|
# Cambio de fuente de audio: ademas del reenganche que hara el
|
|
# escenario, enrutamos aqui por si esta cerrado o si projectM (que
|
|
# no pasa por el navegador) es el motor activo. Con un microfono
|
|
# concreto elegido no se toca nada: lo fija el propio navegador.
|
|
src = patch.get("source") if engine == "audio" else None
|
|
if src == "monitor" or (src == "mic" and not state["audio"]["device"]):
|
|
threading.Timer(
|
|
1.5, lambda: route_captures(src, quiet=True)).start()
|
|
broadcast()
|
|
|
|
|
|
_map_timer = None
|
|
|
|
|
|
def save_mapping():
|
|
"""Guarda el mapping con un segundo de retardo. Arrastrar un punto emite
|
|
~16 veces por segundo y no queremos escribir la tarjeta SD 16 veces por
|
|
segundo: se reprograma y solo escribe cuando el usuario suelta."""
|
|
global _map_timer
|
|
if _map_timer:
|
|
_map_timer.cancel()
|
|
_map_timer = threading.Timer(1.0, _write_mapping)
|
|
_map_timer.daemon = True
|
|
_map_timer.start()
|
|
|
|
|
|
def _write_mapping():
|
|
try:
|
|
with lock:
|
|
datos = json.dumps(state["mapping"], ensure_ascii=False, indent=2)
|
|
with open(DATA / "mapping.json", "w", encoding="utf-8") as fh:
|
|
fh.write(datos)
|
|
except OSError as e:
|
|
notify("warn", f"No se pudo guardar el mapping: {e}")
|
|
|
|
|
|
def capas_saneadas(lista, clave_geo):
|
|
"""Descarta lo que no tenga forma de capa/mascara y limita cuantas se
|
|
aceptan. Sin esto, cualquiera en la red puede dejar mapping.json lleno de
|
|
basura (y persiste entre reinicios)."""
|
|
limpio = []
|
|
for it in lista[:64]:
|
|
if isinstance(it, dict) and isinstance(it.get("id"), str) \
|
|
and isinstance(it.get(clave_geo), list):
|
|
limpio.append(it)
|
|
return limpio
|
|
|
|
|
|
@socketio.on("set_mapping")
|
|
def on_set_mapping(data):
|
|
"""Projection mapping: activar/editar y guardar las superficies.
|
|
Lo usan el escenario (al arrastrar esquinas) y el panel (activar, añadir,
|
|
borrar, reset). Guarda en data/mapping.json y difunde a todos."""
|
|
data = data or {}
|
|
with lock:
|
|
m = state["mapping"]
|
|
if "enabled" in data:
|
|
m["enabled"] = bool(data["enabled"])
|
|
if "edit" in data:
|
|
m["edit"] = bool(data["edit"])
|
|
if "surfaces" in data and isinstance(data["surfaces"], list):
|
|
m["surfaces"] = capas_saneadas(data["surfaces"], "points")
|
|
if "masks" in data and isinstance(data["masks"], list):
|
|
m["masks"] = capas_saneadas(data["masks"], "corners")
|
|
m.setdefault("masks", [])
|
|
save_mapping()
|
|
broadcast()
|
|
|
|
|
|
@socketio.on("engine_command")
|
|
def on_engine_command(data):
|
|
action = (data or {}).get("action")
|
|
if state["engine"] == "projectm":
|
|
pm = state["projectm"]
|
|
if action in ("next", "prev"):
|
|
projectm_key({"next": "n", "prev": "p"}[action])
|
|
elif action == "lock":
|
|
# La tecla espacio congela/suelta el visual en pantalla al
|
|
# instante, sin relanzar projectM (el titulo pone [locked]).
|
|
projectm_key("space")
|
|
with lock:
|
|
pm["locked"] = not pm.get("locked")
|
|
broadcast()
|
|
elif action == "fix_current":
|
|
# Fija en las listas la categoria y el visual que se ven ahora:
|
|
# projectM se relanza rotando solo entre variantes de ese look.
|
|
if pm.get("nowCategory") and pm.get("nowVisual"):
|
|
with lock:
|
|
pm["category"] = pm["nowCategory"]
|
|
pm["preset"] = pm["nowVisual"]
|
|
pm["variante"] = ""
|
|
start_projectm()
|
|
broadcast()
|
|
else:
|
|
notify("warn", "Todavia no se que visual esta en pantalla: "
|
|
"espera un par de segundos y vuelve a probar.")
|
|
else:
|
|
socketio.emit("stage_command", {"action": action,
|
|
"value": (data or {}).get("value")})
|
|
|
|
|
|
@socketio.on("pm_favorito")
|
|
def on_pm_favorito(data):
|
|
"""Favoritos de projectM: guardar el visual en pantalla, quitar uno,
|
|
o lanzar uno a dedo (para pinchar visuales en directo)."""
|
|
data = data or {}
|
|
action = data.get("action")
|
|
pm = state["projectm"]
|
|
if action == "add":
|
|
fav = {"categoria": pm.get("nowCategory"),
|
|
"visual": pm.get("nowVisual"),
|
|
"variante": pm.get("nowPlaying")}
|
|
if not all(fav.values()):
|
|
notify("warn", "Todavia no se que visual esta en pantalla: "
|
|
"espera un par de segundos y vuelve a probar.")
|
|
return
|
|
if fav not in pm["favoritos"]:
|
|
with lock:
|
|
pm["favoritos"].append(fav)
|
|
save_favoritos()
|
|
notify("info", f"Favorito guardado: {fav['variante']}")
|
|
elif action == "del":
|
|
fav = {k: data.get(k) for k in ("categoria", "visual", "variante")}
|
|
with lock:
|
|
pm["favoritos"] = [f for f in pm["favoritos"] if f != fav]
|
|
save_favoritos()
|
|
elif action == "play":
|
|
with lock:
|
|
pm["category"] = data.get("categoria") or ""
|
|
pm["preset"] = data.get("visual") or ""
|
|
pm["variante"] = data.get("variante") or ""
|
|
if state["engine"] == "projectm" and state["power"]:
|
|
start_projectm()
|
|
broadcast()
|
|
|
|
|
|
@socketio.on("stage_meta")
|
|
def on_stage_meta(data):
|
|
"""El escenario nos envia las listas que solo conoce el navegador."""
|
|
data = data or {}
|
|
with lock:
|
|
if isinstance(data.get("butterchurnPresets"), list):
|
|
state["meta"]["butterchurnPresets"] = data["butterchurnPresets"]
|
|
if isinstance(data.get("audioDevices"), list):
|
|
state["meta"]["audioDevices"] = data["audioDevices"]
|
|
if isinstance(data.get("cameraDevices"), list):
|
|
state["meta"]["cameraDevices"] = data["cameraDevices"]
|
|
broadcast()
|
|
|
|
|
|
@socketio.on("rescan_devices")
|
|
def on_rescan_devices(data=None):
|
|
"""Pide al escenario que vuelva a buscar microfonos y camaras.
|
|
Aprovecha para refrescar la lista de pantallas conectadas."""
|
|
with lock:
|
|
state["meta"]["monitors"] = list_monitors()
|
|
socketio.emit("stage_rescan")
|
|
broadcast()
|
|
|
|
|
|
@socketio.on("reacquire_audio")
|
|
def on_reacquire_audio(data=None):
|
|
"""Pide al escenario que vuelva a conectar la captura del microfono."""
|
|
socketio.emit("stage_reacquire")
|
|
|
|
|
|
def pactl_default(kind):
|
|
"""Nombre del sink o source por defecto ('sink' | 'source'), o None."""
|
|
try:
|
|
out = subprocess.check_output(["pactl", f"get-default-{kind}"],
|
|
text=True, timeout=5).strip()
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
return out or None
|
|
|
|
|
|
# Aplicaciones cuyos streams de captura enrutamos: el navegador de las
|
|
# visuales (Chromium/Firefox) y projectM (SDL), que siempre abre la
|
|
# entrada por defecto y no se puede configurar de otra forma.
|
|
CAPTURE_APPS = ("chrom", "firefox", "projectm")
|
|
|
|
|
|
def capture_streams():
|
|
"""Indices de los streams de captura de las visuales, leidos de pactl."""
|
|
try:
|
|
out = subprocess.check_output(["pactl", "list", "source-outputs"],
|
|
text=True, timeout=5)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return []
|
|
indices, current = [], None
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("Source Output #"):
|
|
current = line.split("#", 1)[1]
|
|
elif current and ("application.process.binary" in line
|
|
or "application.name" in line):
|
|
if any(app in line.lower() for app in CAPTURE_APPS):
|
|
indices.append(current)
|
|
current = None
|
|
return indices
|
|
|
|
|
|
def route_captures(target, quiet=False, attempts=10):
|
|
"""Mueve los streams de captura al 'monitor' de la salida por defecto
|
|
(target='monitor': lo que suena en el equipo) o los devuelve a la
|
|
entrada por defecto (target='mic').
|
|
|
|
Hace falta porque Chromium no lista los monitores de PulseAudio/PipeWire
|
|
al enumerar dispositivos y projectM (SDL) captura siempre la entrada por
|
|
defecto: la unica via fiable es mover sus streams con pactl. Requiere que
|
|
las visuales corran en esta misma maquina (kiosko o modo portatil)."""
|
|
if target == "monitor":
|
|
sink = pactl_default("sink")
|
|
dest = sink + ".monitor" if sink else None
|
|
else:
|
|
dest = pactl_default("source")
|
|
if not dest:
|
|
notify("warn", "pactl no pudo averiguar la salida/entrada por "
|
|
"defecto del sistema. Revisa el audio con 'pactl "
|
|
"info'; mientras tanto usa el microfono.")
|
|
return
|
|
# Los streams tardan un instante en aparecer tras abrir la captura.
|
|
streams = []
|
|
for _ in range(attempts):
|
|
streams = capture_streams()
|
|
if streams:
|
|
break
|
|
time.sleep(0.3)
|
|
if not streams:
|
|
if not quiet:
|
|
notify("warn", "No hay ningun stream de captura que mover (ni "
|
|
"navegador ni projectM). Recarga el escenario o "
|
|
"reinicia FOSFENO y vuelve a intentarlo.")
|
|
return
|
|
moved = all(
|
|
subprocess.run(["pactl", "move-source-output", idx, dest],
|
|
check=False).returncode == 0
|
|
for idx in streams)
|
|
if not moved:
|
|
notify("warn", f"pactl no pudo mover la captura a '{dest}'. Si la "
|
|
"salida acaba de cambiar (p. ej. bluetooth), pulsa "
|
|
"'Aplicar entrada' otra vez; si sigue fallando, "
|
|
"revisa 'pactl list sources short'.")
|
|
elif target == "monitor" and not quiet:
|
|
notify("info", f"Audio del sistema conectado ({dest}).")
|
|
|
|
|
|
@socketio.on("audio_route")
|
|
def on_audio_route(data):
|
|
"""El escenario pide enrutar la captura: 'monitor' (lo que suena en el
|
|
equipo) o 'mic' (entrada por defecto)."""
|
|
target = (data or {}).get("target")
|
|
if target in ("monitor", "mic"):
|
|
route_captures(target)
|
|
|
|
|
|
@socketio.on("stage_status")
|
|
def on_stage_status(data):
|
|
if isinstance(data, dict):
|
|
with lock:
|
|
if "label" in data:
|
|
state["status"]["label"] = data["label"]
|
|
if "bpm" in data:
|
|
state["audio"]["bpm"] = data["bpm"]
|
|
socketio.emit("status", {"label": state["status"]["label"],
|
|
"bpm": state["audio"]["bpm"]})
|
|
|
|
|
|
@socketio.on("stage_notify")
|
|
def on_stage_notify(data):
|
|
"""El escenario (navegador) nos comunica un error para mostrarlo en el panel."""
|
|
data = data or {}
|
|
notify(data.get("level", "error"),
|
|
data.get("message", "Error en el escenario de visuales."))
|
|
|
|
|
|
@socketio.on("rescan_videos")
|
|
def on_rescan_videos(data=None):
|
|
with lock:
|
|
state["meta"]["videos"] = list_videos()
|
|
state["meta"]["visuales"] = load_visuales()
|
|
broadcast()
|
|
|
|
|
|
@socketio.on("system")
|
|
def on_system(data):
|
|
action = (data or {}).get("action")
|
|
if action not in ("reboot", "shutdown"):
|
|
return
|
|
# En modo portatil estos botones apagarian el ordenador de trabajo:
|
|
# solo tienen sentido en la Raspberry del kiosko.
|
|
if NO_KIOSK:
|
|
notify("warn", "Modo portatil: el boton de reiniciar/apagar es "
|
|
"para la Raspberry. En este equipo hazlo a mano.")
|
|
return
|
|
if action == "reboot":
|
|
subprocess.Popen(["sudo", "reboot"])
|
|
else:
|
|
subprocess.Popen(["sudo", "poweroff"])
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Arranque
|
|
# --------------------------------------------------------------------------
|
|
def setup_audio():
|
|
"""Marca el microfono USB como fuente de audio por defecto (best-effort)."""
|
|
# En modo portatil no hay micro USB dedicado y la fuente se elige en el
|
|
# panel (micro o "audio del navegador"). Evitamos forzar nada y el aviso
|
|
# de "no hay micro USB", que aqui solo confundiria.
|
|
if NO_KIOSK:
|
|
return
|
|
match = CFG.get("audio", {}).get("matchSource", "usb").lower()
|
|
try:
|
|
out = subprocess.check_output(["pactl", "list", "short", "sources"],
|
|
text=True, timeout=5)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return
|
|
for line in out.splitlines():
|
|
cols = line.split("\t")
|
|
if len(cols) >= 2 and match in cols[1].lower() \
|
|
and "monitor" not in cols[1].lower():
|
|
subprocess.run(["pactl", "set-default-source", cols[1]], check=False)
|
|
print("[FOSFENO] Microfono por defecto:", cols[1])
|
|
return
|
|
notify("warn", "No se detecto ningun microfono USB. Conecta uno y "
|
|
"reinicia, o elige la entrada a mano en el panel.")
|
|
|
|
|
|
def watchdog():
|
|
"""Reabre Chromium si se cierra (salvo cuando projectM esta activo)."""
|
|
while True:
|
|
time.sleep(10)
|
|
if refresh_network():
|
|
broadcast()
|
|
if not NO_KIOSK and state["engine"] != "projectm" \
|
|
and not is_running(procs["chromium"]):
|
|
notify("warn", "El navegador de las visuales se cerro. "
|
|
"Reabriendolo automaticamente.")
|
|
start_chromium()
|
|
|
|
|
|
def check_install():
|
|
"""Avisa al panel si la instalacion esta incompleta."""
|
|
needed = ["socket.io.min.js", "butterchurn.min.js",
|
|
"butterchurn-presets.min.js", "hydra-synth.js"]
|
|
missing = [f for f in needed if not (WEB / "lib" / f).exists()]
|
|
if missing:
|
|
notify("error", "Faltan librerias web (" + ", ".join(missing) +
|
|
"). Vuelve a ejecutar install.sh.")
|
|
if not (WEB / "lib" / "codemirror" / "codemirror.js").exists():
|
|
notify("warn", "Falta CodeMirror: el editor de codigo no funcionara. "
|
|
"Vuelve a ejecutar install.sh.")
|
|
|
|
|
|
def main():
|
|
binary = CFG.get("projectm", {}).get("binary", "projectMSDL")
|
|
state["projectm"]["available"] = shutil.which(binary) is not None
|
|
state["meta"]["projectmPresets"] = list_projectm_presets()
|
|
state["meta"]["monitors"] = list_monitors()
|
|
print(f"[FOSFENO] projectM nativo: "
|
|
f"{'disponible' if state['projectm']['available'] else 'no instalado'}")
|
|
print(f"[FOSFENO] Videos encontrados: {len(state['meta']['videos'])}")
|
|
|
|
check_install()
|
|
setup_audio()
|
|
refresh_network()
|
|
# En modo portatil el script 'fosfeno' abre el navegador; el servidor no.
|
|
if not NO_KIOSK:
|
|
threading.Timer(3.0, start_chromium).start()
|
|
threading.Thread(target=watchdog, daemon=True).start()
|
|
threading.Thread(target=watch_projectm_log, daemon=True).start()
|
|
|
|
net = state["network"]
|
|
suffix = "" if PORT == 80 else f":{PORT}"
|
|
print("[FOSFENO] ===================================================")
|
|
print("[FOSFENO] Panel de control disponible en:")
|
|
print(f"[FOSFENO] http://{net['ip']}{suffix}/")
|
|
print(f"[FOSFENO] http://{net['hostname']}.local{suffix}/")
|
|
print("[FOSFENO] ===================================================")
|
|
socketio.run(app, host=HOST, port=PORT, allow_unsafe_werkzeug=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|