Mapping in-browser (Fase 1) + trabajo en curso

Projection mapping editable desde el navegador, sin herramientas externas:
- web/stage/mapper.js (nuevo): compositor WebGL que texturiza el motor activo
  sobre superficies deformables (homografia por vertice: keystone correcto).
- Editor con raton en el escenario (crear, arrastrar esquinas, mover, borrar)
  y previsualizacion arrastrable en el panel (raton/tactil) para editar desde
  el movil sin tocar la ventana de visuales.
- Backend: estado "mapping" + evento set_mapping, persistido en data/mapping.json
  (ignorado en git). Seccion "Mapping" en el panel + texto de ayuda.
- Solo motores web (butterchurn/hydra/shaders/mixer); projectM no se mapea.
- .gitignore: excluye config de runtime (mapping.json, pm-favoritos.json).

Incluye ademas trabajo en curso previo sin commitear (server.py, stage.js,
panel.js/css, ayuda.json, hydra-sketches.json, problemas.md,
start-projectm.sh), subido junto a peticion del autor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-07-27 00:19:30 +02:00
parent 0b1546cecc
commit 8dd58bb293
12 changed files with 1815 additions and 85 deletions

View file

@ -14,6 +14,7 @@ Motores: projectm (nativo), butterchurn, hydra, shaders (GLSL) y mixer
import json
import os
import re
import shutil
import socket
import subprocess
@ -21,8 +22,9 @@ import threading
import time
from pathlib import Path
from flask import Flask, send_from_directory
from flask import Flask, request, send_from_directory
from flask_socketio import SocketIO
from werkzeug.utils import secure_filename
# --------------------------------------------------------------------------
# Rutas y configuracion
@ -32,6 +34,8 @@ 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):
@ -51,6 +55,7 @@ PORT = int(os.environ.get("FOSFENO_PORT") or CFG.get("server", {}).get("port", 8
NO_KIOSK = bool(os.environ.get("FOSFENO_NO_KIOSK"))
app = Flask(__name__, static_folder=None)
app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 ** 3 # subidas de hasta 4 GB
socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*")
# --------------------------------------------------------------------------
@ -73,7 +78,28 @@ def list_videos():
if not VIDEOS.is_dir():
return []
return sorted(f.name for f in VIDEOS.iterdir()
if f.suffix.lower() in VIDEO_EXT)
if f.suffix.lower() in MEDIA_EXT)
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
# --------------------------------------------------------------------------
@ -85,7 +111,9 @@ state = {
"engine": DEFAULTS.get("engine", "butterchurn"),
"power": True,
"sensitivity": float(DEFAULTS.get("sensitivity", 1.0)),
"audio": {"device": "", "bpm": 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,
@ -99,18 +127,40 @@ state = {
"code": first_code(SHADERS, DEFAULTS.get("shader", "")),
},
"mixer": {
"source": "cam", "camOn": True, "cameraId": 0, "video": "",
"mix": 0.5, "blendMode": "blend",
# 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": {"available": 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": load_json(DATA / "mapping.json",
{"enabled": False, "edit": False, "surfaces": []}),
"meta": {
"butterchurnPresets": [],
"audioDevices": [],
"cameraDevices": [],
"videos": list_videos(),
"projectmPresets": {},
"monitors": [],
},
"status": {"label": ""},
"notifications": NOTIFY_HISTORY,
@ -209,16 +259,135 @@ def stop_projectm():
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 start_projectm():
"""Lanza projectM nativo; su ventana se superpone a Chromium."""
"""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") and (base / pm["category"]).is_dir():
path = base / pm["category"]
try:
procs["projectm"] = subprocess.Popen(["bash", str(script)])
print("[FOSFENO] projectM lanzado.")
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 = base / pm["category"] / pm["preset"]
if 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 = (base / pm["category"] / pm["preset"]
/ (pm["variante"] + ".milk"))
if 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):
@ -233,6 +402,53 @@ def projectm_key(key):
)
# 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"]
@ -279,6 +495,42 @@ 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)."""
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()
notify("info", f"'{name}' anadido a la galeria del mezclador.")
broadcast()
return {"ok": True, "name": name}
# --------------------------------------------------------------------------
# Eventos WebSocket
# --------------------------------------------------------------------------
@ -343,12 +595,51 @@ def on_run_code(data):
@socketio.on("update_settings")
def on_update_settings(data):
"""Actualiza ajustes de un motor: butterchurn, mixer o audio."""
"""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") and isinstance(patch, dict):
if engine in ("butterchurn", "mixer", "audio", "projectm") \
and isinstance(patch, dict):
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()
def save_mapping():
try:
with open(DATA / "mapping.json", "w", encoding="utf-8") as fh:
json.dump(state["mapping"], fh, ensure_ascii=False, indent=2)
except OSError as e:
notify("warn", f"No se pudo guardar el mapping: {e}")
@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"] = data["surfaces"]
save_mapping()
broadcast()
@ -356,12 +647,69 @@ def on_update_settings(data):
def on_engine_command(data):
action = (data or {}).get("action")
if state["engine"] == "projectm":
projectm_key({"next": "n", "prev": "p"}.get(action, "n"))
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."""
@ -378,8 +726,12 @@ def on_stage_meta(data):
@socketio.on("rescan_devices")
def on_rescan_devices():
"""Pide al escenario que vuelva a buscar microfonos y camaras."""
"""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")
@ -388,6 +740,96 @@ def on_reacquire_audio():
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):
@ -418,9 +860,17 @@ def on_rescan_videos():
@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"])
elif action == "shutdown":
else:
subprocess.Popen(["sudo", "poweroff"])
@ -429,6 +879,11 @@ def on_system(data):
# --------------------------------------------------------------------------
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"],
@ -475,6 +930,8 @@ def check_install():
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'])}")
@ -486,6 +943,7 @@ def main():
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}"