Capas independientes, projectM en el mapping y catalogo de la galeria
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>
This commit is contained in:
parent
5d5223db9f
commit
f017246f0e
30 changed files with 3771 additions and 292 deletions
|
|
@ -53,10 +53,25 @@ HOST = CFG.get("server", {}).get("host", "0.0.0.0")
|
|||
# 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)
|
||||
app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 ** 3 # subidas de hasta 4 GB
|
||||
socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*")
|
||||
# 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
|
||||
|
|
@ -81,6 +96,19 @@ def list_videos():
|
|||
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...)
|
||||
|
|
@ -152,14 +180,19 @@ state = {
|
|||
# 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": [], "masks": []}),
|
||||
"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": [],
|
||||
},
|
||||
|
|
@ -235,7 +268,8 @@ def start_chromium():
|
|||
notify("error", "No se encontro Chromium. Las visuales web no pueden "
|
||||
"mostrarse. Ejecuta install.sh para instalarlo.")
|
||||
return
|
||||
url = f"http://localhost:{PORT}/stage"
|
||||
# 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}",
|
||||
|
|
@ -326,6 +360,18 @@ def monitor_of_stage():
|
|||
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."""
|
||||
|
|
@ -334,8 +380,10 @@ def start_projectm():
|
|||
pm = state["projectm"]
|
||||
base = DATA / "presets-projectm"
|
||||
path = base
|
||||
if pm.get("category") and (base / pm["category"]).is_dir():
|
||||
path = base / pm["category"]
|
||||
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):
|
||||
|
|
@ -344,15 +392,15 @@ def start_projectm():
|
|||
# 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():
|
||||
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 = (base / pm["category"] / pm["preset"]
|
||||
/ (pm["variante"] + ".milk"))
|
||||
if origen.is_file():
|
||||
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)
|
||||
|
|
@ -511,6 +559,10 @@ def pm_variantes():
|
|||
@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
|
||||
|
|
@ -527,6 +579,7 @@ def upload_media():
|
|||
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}
|
||||
|
|
@ -536,8 +589,13 @@ def upload_media():
|
|||
# Eventos WebSocket
|
||||
# --------------------------------------------------------------------------
|
||||
@socketio.on("connect")
|
||||
def 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")
|
||||
|
|
@ -594,6 +652,35 @@ def on_run_code(data):
|
|||
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."""
|
||||
|
|
@ -601,6 +688,7 @@ def on_update_settings(data):
|
|||
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)
|
||||
|
|
@ -618,14 +706,43 @@ def on_update_settings(data):
|
|||
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:
|
||||
json.dump(state["mapping"], fh, ensure_ascii=False, indent=2)
|
||||
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.
|
||||
|
|
@ -639,9 +756,9 @@ def on_set_mapping(data):
|
|||
if "edit" in data:
|
||||
m["edit"] = bool(data["edit"])
|
||||
if "surfaces" in data and isinstance(data["surfaces"], list):
|
||||
m["surfaces"] = data["surfaces"]
|
||||
m["surfaces"] = capas_saneadas(data["surfaces"], "points")
|
||||
if "masks" in data and isinstance(data["masks"], list):
|
||||
m["masks"] = data["masks"]
|
||||
m["masks"] = capas_saneadas(data["masks"], "corners")
|
||||
m.setdefault("masks", [])
|
||||
save_mapping()
|
||||
broadcast()
|
||||
|
|
@ -729,7 +846,7 @@ def on_stage_meta(data):
|
|||
|
||||
|
||||
@socketio.on("rescan_devices")
|
||||
def 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:
|
||||
|
|
@ -739,7 +856,7 @@ def on_rescan_devices():
|
|||
|
||||
|
||||
@socketio.on("reacquire_audio")
|
||||
def on_reacquire_audio():
|
||||
def on_reacquire_audio(data=None):
|
||||
"""Pide al escenario que vuelva a conectar la captura del microfono."""
|
||||
socketio.emit("stage_reacquire")
|
||||
|
||||
|
|
@ -855,9 +972,10 @@ def on_stage_notify(data):
|
|||
|
||||
|
||||
@socketio.on("rescan_videos")
|
||||
def on_rescan_videos():
|
||||
def on_rescan_videos(data=None):
|
||||
with lock:
|
||||
state["meta"]["videos"] = list_videos()
|
||||
state["meta"]["visuales"] = load_visuales()
|
||||
broadcast()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue