diff --git a/.gitignore b/.gitignore index 81f1b7a..af31128 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ data/presets-projectm/ data/videos/* !data/videos/README.txt +# Catalogo de la galeria y sus miniaturas: describen los videos locales, que +# tampoco van al repo. Los regenera scripts/catalogar-visuales.py +data/visuales.json +data/miniaturas/ + # Config de runtime (por instalacion): no va al repo data/mapping.json data/pm-favoritos.json diff --git a/README.md b/README.md index e1f675a..f996a82 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ arranca solo, o en un **portátil Linux** que lanzas cuando quieras. El apartado │ (micro USB) ▼ ┌──────────────────┐ Wi-Fi / Ethernet - │ Raspberry Pi 5 │◀─────────────────────────── http://192.168.1.71/ + │ Raspberry Pi 5 │◀─────────────────────────── http://192.168.1.XX/ │ FOSFENO │ (panel en el móvil) └────────┬─────────┘ │ micro-HDMI @@ -40,9 +40,15 @@ La carpeta [`docs/`](docs/) tiene la guía completa: - [Conectarse al panel](docs/conexion.md) — el código QR, `fosfeno.local` y el router. - [Uso del panel](docs/uso.md) — cómo se maneja y qué hace cada motor. +- [El Mezclador VJ](docs/mezclador.md) — las mezclas: las dos capas, visuales + de fondo, modos de mezcla y efectos. - [Projection mapping](docs/mapping.md) — deformar las visuales para encajarlas en superficies físicas (malla, máscaras) desde el panel. - [Solución de problemas](docs/problemas.md) — qué hacer cuando algo falla. +- [Seguridad](docs/seguridad.md) — el panel está abierto a la red local por + defecto; cómo cerrarlo con una clave si pinchas fuera de casa. +- [Arquitectura](docs/arquitectura.md) — para tocar el código: piezas, estado, + eventos y cómo añadir un motor. El panel además lleva un botón de información en cada apartado: explica qué es, qué necesita y cómo se configura, sin salir del propio panel. @@ -65,12 +71,30 @@ Visuales de ejemplo — el motor Hydra con su editor de código en vivo: Hydra -## Projection mapping +## Projection mapping por capas -Las visuales web (Butterchurn, Hydra, Shaders y Mezclador) se pueden **deformar -para encajarlas en superficies físicas** desde el panel: superficies con malla -(curvas), varias a la vez y máscaras para tapar zonas, arrastrando con el ratón -o el dedo. Se guarda solo en `data/mapping.json`. Guía: [docs/mapping.md](docs/mapping.md). +Las visuales se pueden **deformar para encajarlas en superficies físicas** desde +el panel: malla deformable (curvas), varias zonas a la vez y máscaras para tapar +derrames, arrastrando con el ratón o el dedo. + +Y cada zona es una **capa con su propio visual**: MilkDrop a la derecha, otro +preset distinto a la izquierda y un vídeo en el centro, a la vez. + +
+ Tres capas con visuales distintos +
+ +| Fuente de una capa | Qué es | +|--------------------|--------| +| El motor de MOTORES | Lo que esté puesto en la otra pestaña del panel | +| Visuales MilkDrop propias | Un Butterchurn solo para esa capa, con su preset | +| Shader GLSL | Un shader de la librería, reactivo al audio | +| Clip o imagen | Un archivo de `data/videos`, en bucle | +| Cámara | La webcam | +| Negro | Tapa la zona | + +Con opacidad y mezcla (normal o aditiva) por capa. Se guarda solo en +`data/mapping.json`. Guía: [docs/mapping.md](docs/mapping.md). ## Hardware necesario @@ -173,6 +197,9 @@ Así se ve el panel de control: Panel de control de FOSFENO +El panel tiene dos pestañas, a la derecha del título: **MOTORES** (el directo) +y **MAPPING** (el montaje sobre la superficie física). + Desde el panel puedes: - Encender/apagar las visuales y cambiar de motor. @@ -182,9 +209,11 @@ Desde el panel puedes: **sincronizado al compás**. - **Hydra / Shaders**: editor de código integrado para **escribir o pegar** tu propio código, más una **librería de fragmentos** lista para cargar. -- **Mezclador VJ**: activar la cámara, elegir un clip de vídeo, modo de - mezcla y efectos de color (tono, saturación, colorama, posterizado, - pixelado, caleidoscopio, feedback, invertir…). +- **Mezclador VJ**: elegir el fondo (cámara o visuales MilkDrop), el clip de + encima, el modo de mezcla y los efectos de color (tono, saturación, + colorama, posterizado, pixelado, caleidoscopio, feedback, invertir…). +- **Mapping**: encajar la imagen sobre la superficie física arrastrando los + puntos, con malla deformable y máscaras. FOSFENO en acción — vídeo corto de demostración: @@ -194,12 +223,19 @@ FOSFENO en acción — vídeo corto de demostración: Si el reproductor no se ve, descarga el vídeo: [demo.mp4](docs/assets/demo.mp4) -### Modo Mezclador (cámara + vídeo) +### Modo Mezclador (cámara + vídeo + visuales de fondo) -Copia tus clips en `data/videos/` (formatos `.mp4`, `.webm`, `.mov`…) y -aparecerán en el panel. El mezclador genera código Hydra por debajo a partir -de los controles, así que mezcla en tiempo real la webcam, los clips y los -efectos de color — el equivalente a Resolume, pero corriendo en la propia Pi. +El mezclador monta **dos capas**: un **fondo** —la webcam o las propias +**visuales MilkDrop**— y un **clip** encima (vídeo o imagen), combinados con el +modo de mezcla y los efectos de color. Con un clip de silueta sobre negro y el +modo *Recorte*, el personaje queda **delante de MilkDrop** latiendo con la +música. + +Los clips se copian a `data/videos/` (`.mp4` H.264, `.webm`, imágenes) o se +suben desde el propio panel. Por debajo, el mezclador genera código Hydra a +partir de los controles: el equivalente a Resolume, corriendo en la propia Pi. + +Guía completa: [El Mezclador VJ](docs/mezclador.md). ## Estructura @@ -210,8 +246,10 @@ FOSFENO/ ├── backend/server.py Servidor: web + WebSocket + gestión de procesos ├── scripts/lib.sh Funciones de los scripts (logs, versiones) ├── web/panel/ Panel de control (móvil) -├── web/stage/ Escenario en Chromium (todos los motores web) +├── web/stage/stage.js Escenario en Chromium (todos los motores web) +├── web/stage/mapper.js Compositor de projection mapping (WebGL) ├── docs/ Documentación completa +├── docs/arquitectura.md Cómo está montado, para tocar el código ├── data/hydra-sketches.json Sketches de Hydra de fábrica ├── data/hydra-snippets.json Librería de fragmentos de Hydra para el editor ├── data/shaders.json Shaders GLSL (editables) diff --git a/backend/server.py b/backend/server.py index d46667c..1d4de4b 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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() diff --git a/config.json b/config.json index 5071c9c..c54040c 100644 --- a/config.json +++ b/config.json @@ -17,6 +17,10 @@ "hostname": "fosfeno", "_comment": "Nombre de red de la Raspberry. El panel queda en http://.local/" }, + "auth": { + "token": "", + "_comment": "Vacio = panel abierto a toda la red local (como siempre). Si pones una clave, hara falta para conectarse y para subir archivos; el codigo QR del proyector ya la lleva dentro, asi que se entra escaneando igual. Recomendado en locales publicos: sin clave, cualquiera en la wifi puede apagar el equipo o meter codigo en las visuales." + }, "defaults": { "engine": "butterchurn", "sensitivity": 1.0, diff --git a/data/ayuda.json b/data/ayuda.json index 3bade4e..04a8c2a 100644 --- a/data/ayuda.json +++ b/data/ayuda.json @@ -13,9 +13,12 @@ "title": "Mapping (projection mapping)", "body": [ "Que es: deforma la salida de las visuales para encajarla en superficies fisicas (una pared en angulo, cajas, un objeto). En vez de un rectangulo plano, ajustas la imagen a la forma real.", - "Como se usa: marca 'Activar mapping' y 'Modo edicion'. En la ventana de las visuales (el proyector) veras cada superficie con sus cuatro esquinas: arrastralas con el raton para encajarla. Doble clic crea una superficie nueva; la tecla Supr borra la seleccionada.", - "Varias superficies: puedes crear varias (con 'Anadir superficie' o doble clic) y mapear la misma imagen en distintas zonas. Cada una se lista en el panel con su boton de borrar.", - "Se guarda solo en data/mapping.json y se mantiene al reiniciar. Quita 'Modo edicion' para ocultar los tiradores mientras pinchas.", + "Donde esta: en su propia pestana MAPPING, arriba a la derecha del titulo. La pestana MOTORES tiene lo de siempre (encendido, audio, motores y sus controles) y esta lo de encajar la imagen, para que no se mezclen el montaje y el directo. Cuando el mapping esta activado sale un punto verde en la pestana, aunque estes mirando MOTORES.", + "Como se usa: marca 'Activar mapping' y pulsa '+ Superficie'. En la previsualizacion del panel arrastra los puntos (con el raton o con el dedo) hasta encajar la forma sobre tu superficie real. Se ve en el proyector al instante.", + "Curvar: selecciona una superficie tocando dentro de ella y usa '+ malla' para subdividirla (hasta 6x6). Con la malla subdividida puedes arrastrar cualquier punto, no solo las esquinas, y adaptarla a superficies que no son planas.", + "'+ Mascara' anade un recuadro negro que tapa lo que no quieres que se vea (bordes, derrames de luz, huecos entre superficies).", + "'Modo edicion' saca ademas los tiradores sobre las propias visuales, por si prefieres ajustar alli con un raton. Quitalo para que no se vean mientras pinchas.", + "Se guarda solo en data/mapping.json y se mantiene al reiniciar.", "Ojo: no deforma projectM (que va en su propia ventana nativa). Usa Butterchurn, Hydra, Shaders o el Mezclador para tener todo mapeable." ] }, @@ -68,14 +71,16 @@ "mixer": { "title": "Motor Mezclador VJ", "body": [ - "Que es: el modo de video. Mezcla la imagen de una camara web con clips de video y efectos de color. Es lo mas parecido a un programa de VJ como Resolume.", - "Requisitos: una webcam USB que cumpla el estandar UVC. Para los clips, copia tus archivos de video en la carpeta data/videos del proyecto; van bien los .mp4 (H.264) y los .webm.", - "Anadir material: con el boton 'Subir video o imagen' del panel puedes mandar clips (mp4, webm...) e imagenes (jpg, png, gif, webp) a la galeria desde el movil o el ordenador, sin tocar carpetas. Tambien puedes generar clips automaticos de tus pelis con DETEKTION (carpeta VISUALES/DETEKTION).", - "Como se configura: elige la fuente (camara, video o mezcla de las dos), el modo de mezcla y los efectos de color con los controles deslizantes. La casilla de pulso al ritmo hace que la imagen lata con los graves.", - "Personajes sobre visuales: pon Fuente en Mezcla, Fondo en 'Visuales Butter', elige un clip _silueta-negro de la galeria (los de DETEKTION) y modo de mezcla 'Recorte'. El negro del clip se vuelve transparente y el personaje queda delante de MilkDrop latiendo con la musica. El deslizador de mezcla ajusta el umbral del recorte (subelo si queda halo oscuro en los bordes).", - "El preset de MilkDrop del fondo es el que estuviera elegido en el motor Butter: escogelo alli antes de entrar al Mezclador. projectM (el nativo) no puede ir de fondo: corre en una ventana aparte, fuera del navegador.", - "Si la pantalla se queda en negro, mira el aviso del panel: dice si falta elegir video, si la camara esta apagada o si algo fallo. La camara se reintenta apagando y encendiendo su casilla; un video que no carga se reintenta eligiendolo otra vez en la lista.", - "Si tienes mas de una camara, eligela en la lista de camaras del panel. El boton Actualizar lista de videos vuelve a leer la carpeta data/videos por si has copiado clips nuevos." + "Que es: el modo de video. Monta DOS CAPAS, una encima de otra, y las combina en directo. Es lo mas parecido a un programa de VJ como Resolume.", + "Las dos capas son siempre las mismas. Capa de abajo (el FONDO): la camara web o las visuales Butter (MilkDrop). Capa de encima (el CLIP): un video o una imagen de la galeria. El panel esta numerado en ese orden: 1 que se ve, 2 el fondo, 3 el clip, 4 como se juntan.", + "1 - Que se ve. 'Solo el fondo' ensena unicamente la capa de abajo. 'Solo el clip' ensena unicamente el video. 'Los dos' los combina, y es el unico caso en el que aparece el paso 4.", + "2 - Fondo. Aqui esta la respuesta a 'como pongo visuales de fondo': cambia el desplegable de Camara a 'Visuales Butter (MilkDrop)'. La camara se apaga sola (con su piloto) y su sitio lo ocupan las visuales. Debajo aparece el preset de MilkDrop que hace de fondo, con Anterior/Siguiente y la casilla de cambio automatico: es el mismo preset del motor Butter, asi que lo que elijas aqui queda elegido tambien alli.", + "3 - Clip. Los videos e imagenes de la galeria (carpeta data/videos). Con 'Subir video o imagen' mandas material nuevo desde el movil o el ordenador, sin tocar carpetas. Valen .mp4 (H.264), .webm, .jpg, .png, .gif y .webp. Tambien puedes generar clips de siluetas de tus pelis con DETEKTION (carpeta VISUALES/DETEKTION).", + "4 - Como se juntan. 'Recorte' vuelve transparente el negro del clip y deja la figura DELANTE del fondo; ahi el primer deslizador pasa a ser el umbral del recorte (subelo si queda halo oscuro en los bordes). Los demas modos (fundido, diferencia, multiplicar, sumar, capa) mezclan las dos capas enteras.", + "Personajes sobre visuales, la receta completa: 'Los dos' + Fondo 'Visuales Butter' + un clip _silueta-negro de la galeria + modo 'Recorte'. El personaje queda delante de MilkDrop latiendo con la musica.", + "La linea naranja de debajo del paso 4 dice en una frase lo que va a salir por el proyector con los ajustes que tengas puestos. Si no estas seguro de que estas montando, leela.", + "projectM (el nativo) no puede ir de fondo: corre en una ventana aparte, fuera del navegador. El fondo de visuales es siempre Butter.", + "Si la pantalla se queda en negro, mira el aviso del panel: dice si falta elegir video, si la camara esta apagada o si algo fallo. La camara se reintenta apagando y encendiendo su casilla; un video que no carga se reintenta eligiendolo otra vez en la lista." ] }, "audio": { @@ -103,6 +108,32 @@ "Como se configura: elige un ejemplo de la libreria y se carga en el editor, listo para ejecutarse. Puedes modificarlo o pegar codigo tuyo. El boton Ejecutar lanza lo que haya en el editor. El boton Limpiar lo vacia.", "Si el codigo tiene un error, FOSFENO lo avisa en la parte de arriba del panel y el detalle tecnico queda en la consola del navegador." ] + }, + "capas": { + "title": "Capas del mapping", + "body": [ + "Que es: cada capa es una zona de la proyeccion con SU PROPIO visual. Puedes tener MilkDrop a la derecha, otro preset distinto a la izquierda y un video en el centro, cada uno encajado en su sitio.", + "Crear: '+ Capa' anade una zona nueva (sale escalonada para que no tape a la anterior). Selecciona la capa y en Propiedades eliges que se proyecta en ella.", + "El orden importa: la lista se lee como en cualquier mesa de VJ, la de arriba es la que queda DELANTE en el proyector. Con las flechas la subes o la bajas.", + "El circulo de cada fila enciende y apaga la capa sin borrarla: util para probar en directo. La ✕ la borra.", + "Sin ninguna capa y con el mapping activado, sale el motor elegido en MOTORES a pantalla completa, como siempre.", + "'+ Mascara' no es una capa: es un recuadro negro que se pinta encima de todo para tapar derrames de luz." + ] + }, + "fuentes": { + "title": "Que se proyecta en una capa", + "body": [ + "Cada capa elige su fuente por separado. Estas son:", + "El motor de MOTORES: lo que este puesto en la otra pestana (Butterchurn, Hydra, Shaders o el Mezclador). Si pones esto en varias capas, todas ensenan lo mismo: sirve para repetir el mismo visual en varias paredes.", + "Visuales MilkDrop propias: una instancia de Butterchurn SOLO para esta capa, con su preset. Es lo que permite tener dos looks de MilkDrop distintos a la vez, uno en cada zona. Puedes fijar un preset o dejar que vaya cambiando solo cada X segundos.", + "Shader GLSL: uno de los shaders de la libreria, reaccionando al audio igual que en el motor de shaders.", + "Clip o imagen de la galeria: un archivo de data/videos. Los clips van en bucle y sin sonido. Si dos capas usan el mismo clip, se reproduce una sola vez y se comparte (no cuesta el doble).", + "Camara: la webcam. Tambien se comparte entre capas.", + "Negro: no pinta nada. Sirve para tapar una zona sin usar mascara.", + "Opacidad y mezcla: la opacidad deja ver lo que hay debajo; 'Sumar' apila luz en vez de tapar (las zonas oscuras de la capa dejan pasar la de abajo), que es como se hacen los solapes bonitos.", + "projectM no puede ir en una capa: es un programa nativo que corre en su propia ventana, fuera del navegador. Para el mismo tipo de visual, usa 'Visuales MilkDrop propias', que son los mismos presets.", + "Ojo con la Raspberry: cada capa de MilkDrop o de shader es un motor de verdad funcionando. Dos o tres van bien; con muchas mas la cosa se arrastra. Los clips y la camara son mucho mas baratos." + ] } } -} \ No newline at end of file +} diff --git a/data/videos/README.txt b/data/videos/README.txt index 494ed56..63a7c29 100644 --- a/data/videos/README.txt +++ b/data/videos/README.txt @@ -15,3 +15,17 @@ Ejemplo desde linea de comandos para reescalar un video pesado: ffmpeg -i original.mp4 -vf scale=-2:720 -c:v libx264 -an clip.mp4 Los videos NO se suben al repositorio (estan en .gitignore). + + +Catalogo +-------- + +Cada vez que metas clips nuevos, vuelve a pasar el catalogador: es lo que hace +que el panel los liste por pelicula y con nombre legible en vez de por nombre +de archivo, y lo que avisa de los que casi no tienen figura. + + python3 scripts/catalogar-visuales.py # cataloga + python3 scripts/catalogar-visuales.py --limpiar # + aparta los que no son + # siluetas (ver docs) + +Explicado a fondo en docs/visuales.md. diff --git a/docs/README.md b/docs/README.md index 4b524f3..5127ff8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,16 +20,32 @@ para consultarse a saltos después. Cómo correr FOSFENO en un portátil Debian, Ubuntu o Mint, sin Raspberry Pi. - [Uso del panel](uso.md) - Cómo se maneja desde el móvil, qué hace cada motor de visuales y cómo se - configura cada opción. + Cómo se maneja desde el móvil, las dos pestañas (MOTORES y MAPPING), qué + hace cada motor de visuales y cómo se configura cada opción. + +- [El Mezclador VJ](mezclador.md) + Las mezclas a fondo: las dos capas, cómo poner visuales de MilkDrop de + fondo, los modos de mezcla y los efectos de color. + +- [La galería de visuales](visuales.md) + El catálogo de clips: cómo se generan las fichas, cómo distingue una silueta + de un trozo de película y por qué unos clips salen marcados como flojos. - [Projection mapping](mapping.md) - Cómo deformar las visuales para encajarlas en superficies físicas: malla, - máscaras y edición desde el panel arrastrando con el ratón o el dedo. + Cómo deformar las visuales para encajarlas en superficies físicas, y cómo + poner **un visual distinto en cada zona**: capas, fuentes, malla y máscaras. - [Solución de problemas](problemas.md) Qué hacer cuando algo no arranca, no se ve o no suena. Incluye cómo leer los mensajes de error que aparecen en el propio panel. +- [Seguridad](seguridad.md) + Qué asume el diseño, qué pasa si pinchas en una wifi pública y cómo cerrar + el panel con una clave compartida (el QR ya la lleva dentro). + +- [Arquitectura](arquitectura.md) + Para quien vaya a tocar el código: las tres piezas, el estado, los eventos + de WebSocket, cómo se añade un motor y los criterios que sigue el proyecto. + Si solo quieres empezar rápido, el archivo `README.md` de la raíz del proyecto tiene la versión resumida. diff --git a/docs/arquitectura.md b/docs/arquitectura.md new file mode 100644 index 0000000..fc01257 --- /dev/null +++ b/docs/arquitectura.md @@ -0,0 +1,237 @@ +# Arquitectura + +Esta página es para quien vaya a **tocar el código** (o para uno mismo dentro de +seis meses). Explica cómo está montado FOSFENO, dónde vive cada cosa y cómo se +añade algo nuevo sin romper el resto. + +## Las tres piezas + +``` + ┌───────────────┐ WebSocket ┌────────────────┐ WebSocket ┌──────────────┐ + │ PANEL │ ────────────▶ │ SERVIDOR │ ────────────▶ │ ESCENARIO │ + │ web/panel/ │ ◀──────────── │ backend/ │ ◀──────────── │ web/stage/ │ + │ (el móvil) │ estado │ server.py │ estado │ (Chromium) │ + └───────────────┘ └───────┬────────┘ └──────┬───────┘ + │ subprocess │ HDMI + ▼ ▼ + projectM (nativo) [ proyector ] +``` + +- **Servidor** (`backend/server.py`, Flask + Flask-SocketIO). Guarda **el + estado**, sirve los archivos web, lanza y vigila projectM, enruta el audio con + `pactl` y persiste lo que hay que persistir. +- **Panel** (`web/panel/`). La interfaz del móvil. **No calcula nada**: refleja + el estado y manda órdenes. +- **Escenario** (`web/stage/`). La página que sale por el proyector. Es la que + **de verdad pinta**: Butterchurn, Hydra, Shaders y el Mezclador viven aquí, + igual que el detector de BPM y la captura de audio. + +Regla de oro: **el estado vive en el servidor**, y todo el mundo lo recibe +entero en cada cambio. Ni el panel ni el escenario guardan verdades propias; si +algo hay que recordar, va al estado. + +## El estado + +Es un único diccionario en `server.py` (`state`) que se difunde con +`socketio.emit("state", state)` en cada cambio. Sus ramas: + +| Rama | Qué guarda | +|------|------------| +| `engine` | motor activo: `projectm`, `butterchurn`, `hydra`, `shaders`, `mixer` | +| `power` | visuales encendidas o en negro | +| `sensitivity` | ganancia aplicada al audio antes del análisis | +| `audio` | fuente (`mic` / `monitor`), tarjeta elegida y BPM detectado | +| `butterchurn` | preset, cambio automático (segundos o compases) y transición | +| `hydra` / `shaders` | el código que se está ejecutando y su etiqueta | +| `mixer` | las dos capas (`source`, `fondo`, `camOn`, `video`), modo de mezcla y efectos | +| `projectm` | categoría, visual, variante, favoritos, monitor, congelado | +| `mapping` | `enabled`, `edit`, `masks[]` y `surfaces[]`, donde cada superficie es una **capa** (geometría + `fuente` + opacidad + mezcla) | +| `meta` | listas detectadas: presets, cámaras, micrófonos, vídeos, monitores | +| `status` / `notifications` / `network` | qué se ve, avisos e IP para el QR | + +Lo que sobrevive a un reinicio se guarda en `data/`: +`mapping.json` (mapping) y `pm-favoritos.json` (favoritos de projectM). El resto +del estado es de la sesión. + +## Eventos de WebSocket + +**Del panel al servidor** + +| Evento | Qué hace | +|--------|----------| +| `set_power`, `set_engine`, `set_sensitivity` | los tres mandos básicos | +| `update_settings {engine, patch}` | parchea una rama del estado (`butterchurn`, `mixer`, `audio`, `projectm`) | +| `run_code {engine, code, label}` | ejecuta código Hydra o GLSL | +| `engine_command {action}` | `next`, `prev`, `lock`, `fix_current` | +| `set_mapping {…}` | activar, editar, superficies y máscaras (persiste) | +| `pm_favorito {action…}` | añadir, borrar o lanzar un favorito de projectM | +| `rescan_devices`, `rescan_videos`, `reacquire_audio` | volver a mirar el hardware y la galería | +| `system {action}` | apagar o reiniciar la máquina | + +**Del escenario al servidor** + +| Evento | Qué hace | +|--------|----------| +| `stage_meta` | publica lo que ha detectado el navegador (micrófonos, cámaras, presets) | +| `stage_status {label, bpm}` | qué se está viendo y a cuántos BPM | +| `stage_notify {level, message}` | manda un aviso a la banda del panel | +| `audio_route {target}` | pide enrutar la captura al monitor del sistema o al micro | +| `set_mapping` | al arrastrar los tiradores sobre las propias visuales | + +**Del servidor a los clientes**: `state` (el estado entero), `status` (solo +etiqueta y BPM, más ligero), `notify`, `stage_command`, `stage_rescan`, +`stage_reacquire`. + +**Nada de esto pide credenciales por defecto.** Cualquiera que alcance el +puerto puede emitir cualquier evento, y eso incluye apagar la máquina y +ejecutar código en el escenario. Si se configura `auth.token`, el handler de +`connect` rechaza a quien no lo traiga en `auth`, y con eso queda cerrado todo +lo demás de golpe. Ver [Seguridad](seguridad.md). + +Lo que entra por el socket **no es de fiar**, así que se filtra antes de tocar +el estado: `update_settings` pasa por una lista blanca de claves por motor +(`CLAVES_AJUSTES`), y `set_mapping` sanea capas y máscaras y limita cuántas +acepta. `mapping.json` se escribe **con un segundo de retardo**: arrastrar un +punto emite ~16 veces por segundo y no queremos escribir la microSD a ese +ritmo. + +## Los motores + +Cada motor es independiente: si a uno le falta su librería o revienta al +arrancar, **los demás siguen funcionando**. `boot()` en `stage.js` los inicia +uno a uno dentro de su propio `try`. + +| Motor | Dónde corre | Lienzo | +|-------|-------------|--------| +| **projectM** | proceso nativo, ventana propia | ninguno (la página se queda en negro debajo) | +| **Butterchurn** | navegador, WebGL | `#butterchurn` | +| **Hydra** | navegador, WebGL | `#hydra` | +| **Shaders** | navegador, WebGL a pelo | `#shaders` | +| **Mezclador** | navegador, **sobre Hydra** | `#hydra` | + +`applyState()` decide qué lienzo se ve y apaga los demás. El mezclador no es un +motor aparte: **genera código Hydra** (`buildMixerCode`) a partir de los +ajustes; por eso comparte lienzo con Hydra y nunca coinciden. + +Butterchurn tiene una particularidad: puede estar pintando **sin ser el motor +activo**, cuando hace de capa de fondo del mezclador. Esa condición está en un +único sitio, `butterLive()`, y de ella dependen el preset, el cambio automático +y los botones de siguiente/anterior. + +### Añadir un motor nuevo + +1. `ENGINES` en `server.py` y una rama en `state` con sus ajustes. +2. Un lienzo en `web/stage/index.html` y su rama en `applyState()`. +3. Un botón `.engine` en `web/panel/index.html` y su tarjeta de controles. +4. Mostrar u ocultar esa tarjeta en `render()` de `panel.js`. +5. Una entrada en `data/ayuda.json` para el botón de información. + +## Audio y BPM + +Toda la cadena de audio está **en el navegador** (`stage.js`): +`getUserMedia` → `GainNode` (la sensibilidad) → `AnalyserNode`. De ahí beben el +detector de ritmo, Butterchurn, Hydra y los shaders. + +El detector (`BeatDetector`) mira la energía de graves, marca un pulso cuando +supera su media reciente, agrupa los intervalos entre pulsos por parecido y se +queda con el grupo mayoritario. El resultado se dobla o se divide hasta caer en +70–180 BPM. Se publica en `fosBeat` y llega a los motores como `u_bpm`/`u_beat` +(shaders) o `bpm` (Hydra). + +La fuente **monitor** («audio del navegador») merece una nota: Chromium oculta +los monitores de PulseAudio/PipeWire al enumerar dispositivos. El plan B es +capturar la entrada por defecto y pedir al servidor que **mueva ese stream** al +monitor de la salida con `pactl`. Solo funciona con servidor y escenario en la +misma máquina — que es el caso del kiosko y del modo portátil. + +## Mapping y capas + +Dos módulos, con una división limpia: **`layers.js` produce imágenes** y +**`mapper.js` las coloca**. + +``` + layers.js mapper.js + ───────── ───────── + capa 1 → Butterchurn → canvas ┐ + capa 2 → clip .mp4 → video ├→ resolver(surface) → textura → malla → #output + capa 3 → ShaderEngine → canvas ┘ (WebGL) + capa 4 → "motor" → el lienzo del motor activo +``` + +**`layers.js`** mantiene una instancia por capa y la pone al día con el estado +(`sync`): crea las nuevas, cambia el preset de las que lo hayan cambiado y +destruye las borradas. Decide también qué se comparte y qué no: + +- Con **estado propio** (Butterchurn, shaders): **una instancia por capa**, para + que dos zonas puedan llevar presets distintos. +- Sin estado (clips, cámara): **compartidas por contenido** con cuenta de usos, + así el mismo clip en dos capas se reproduce una sola vez. + +Cada capa se renderiza en un lienzo de **640×360**: el mapper la estira al +deformarla, y esa resolución es lo que hace viable tener varias a la vez en una +Raspberry. + +**`mapper.js`** es el compositor WebGL. Para cada superficie pide su textura al +`resolver` (que es `FosLayers.fuenteDe`), la sube **una sola vez por fotograma +y por clave de fuente**, y la dibuja sobre la malla con una homografía por +celda. Encima aplica opacidad (`uAlpha`) y modo de mezcla (`blendFunc`: normal +o aditivo), y al final pinta las máscaras en negro. + +Las superficies se guardan en coordenadas **normalizadas 0..1**, así que un +mapping hecho a 1080p sigue valiendo en otra resolución. El formato completo +está documentado en la cabecera de `mapper.js`. Guía de uso: +[Projection mapping](mapping.md). + +Una capa **no puede ser projectM**: es un proceso nativo con su propia ventana, +fuera del alcance de un contexto WebGL del navegador. Su equivalente es la +fuente `butter`, que son los mismos presets de MilkDrop. + +## Estructura de archivos + +``` +FOSFENO/ +├── backend/server.py Estado, WebSocket, procesos, audio, subidas +├── web/panel/ Panel de control (móvil): index.html, panel.js, panel.css +├── web/stage/ Escenario: stage.js (motores + audio), layers.js, mapper.js +├── web/lib/ Librerías servidas tal cual (butterchurn, hydra, codemirror…) +├── data/ Contenido y ajustes que persisten (ver abajo) +├── scripts/ Arranque del kiosko, build de projectM, lib.sh +├── docs/ Esta documentación +├── install.sh Instalador multi-distro (Debian, Fedora, Arch, openSUSE) +└── fosfeno Lanzador del modo portátil +``` + +`data/` mezcla dos cosas: **contenido editable** que sí va al repositorio +(`hydra-sketches.json`, `hydra-snippets.json`, `shaders.json`, `ayuda.json`, +`presets-projectm/`) y **estado de cada instalación** que no va +(`mapping.json`, `pm-favoritos.json`, `videos/`) — ver `.gitignore`. + +## Criterios que sigue el código + +Vale la pena respetarlos al tocar algo: + +- **Nunca callar un fallo.** Todo error acaba en la banda de avisos del panel + con una frase que dice qué hacer, no un volcado técnico. Para eso está + `report()` en el escenario y `notify()` en el servidor. +- **Que un fallo no se lleve el resto por delante.** Sin cámara, sin micro o sin + projectM, lo demás sigue proyectando. +- **El estado manda.** Si algo hay que recordar entre clientes, va al estado; no + se guardan verdades locales en el panel. +- **Textos en castellano y sin jerga.** Tanto la interfaz como los avisos están + escritos para alguien que no ha leído el código. +- **Nada de recursos huérfanos.** Cada instancia de motor lleva su `destruir()` + y suelta lo suyo: `requestAnimationFrame`, temporizadores, `MediaStream` (el + piloto de la cámara) y **el contexto WebGL** (`soltarGL`). El navegador solo + aguanta ~16 contextos y al pasarse mata el más viejo, que es el del + compositor de mapping: dejarlos colgando acaba en proyector negro. +- **Cambiar un ajuste no recrea el motor.** En `layers.js`, la firma de una + fuente decide si la instancia se reaprovecha; los ajustes (preset, cambio + automático) se aplican en caliente con `aplicar()`. + +### Trampa conocida: `hydra.hush()` vacía las fuentes + +Al salir del Mezclador hacia otro motor se llama a `hydra.hush()`, y eso +**borra `s0` y `s1`**. Por eso el escenario recuerda en `mixerS1` qué elemento +tenía enganchado y lo vuelve a enlazar (`reengancharMixer`) al volver: sin +eso, ir a Butter y volver dejaba el mezclador sin la capa de encima. Si algún +día se añade otra fuente de Hydra, hay que recordarla igual. diff --git a/docs/assets/README.md b/docs/assets/README.md index bfca0c4..d210788 100644 --- a/docs/assets/README.md +++ b/docs/assets/README.md @@ -6,7 +6,14 @@ Material gráfico que usa la documentación. - `raspberry-pi-5.jpg` — la placa Raspberry Pi 5. Se usa en `requisitos.md`. - `milkdrop.jpg` — ejemplo de visuales estilo MilkDrop. Se usa en `uso.md`. - `hydra.png` — el entorno de Hydra. Se usa en `uso.md`. -- `panel.png` — captura del panel de control. Se usa en `uso.md` y el README. +- `panel.png` — captura del panel de control (pestaña MOTORES). Se usa en + `uso.md` y el README. +- `mezclador.png` — la tarjeta del Mezclador VJ con visuales de fondo. Se usa + en `mezclador.md`. +- `mapping.png` — la pestaña MAPPING con la previsualización. Se usa en + `mapping.md`. +- `capas.png` — tres capas con visuales distintos (dos MilkDrop + un clip + + una franja de shader). Se usa en `mapping.md` y el README. - `demo.mp4` — vídeo corto de FOSFENO en marcha. Se usa en el README (recomprimido para que no pese; el original eran 80 MB). diff --git a/docs/assets/capas.png b/docs/assets/capas.png new file mode 100644 index 0000000..0ada21e Binary files /dev/null and b/docs/assets/capas.png differ diff --git a/docs/assets/mapping.png b/docs/assets/mapping.png new file mode 100644 index 0000000..1076860 Binary files /dev/null and b/docs/assets/mapping.png differ diff --git a/docs/assets/mezclador.png b/docs/assets/mezclador.png new file mode 100644 index 0000000..a4eb488 Binary files /dev/null and b/docs/assets/mezclador.png differ diff --git a/docs/assets/panel.png b/docs/assets/panel.png index 9d5d1e5..16ea556 100644 Binary files a/docs/assets/panel.png and b/docs/assets/panel.png differ diff --git a/docs/conexion.md b/docs/conexion.md index 263c77c..245aef4 100644 --- a/docs/conexion.md +++ b/docs/conexion.md @@ -36,7 +36,7 @@ a la Raspberry el nombre de red `fosfeno`, y `fosfeno.local` es la forma estándar de localizar un equipo por su nombre en la red local. **Escribir la dirección IP.** Es la que aparece en la pantalla de conexión del -proyector, algo como `http://192.168.1.71/`. Funciona siempre, pero la IP puede +proyector, algo como `http://192.168.1.XX/`. Funciona siempre, pero la IP puede cambiar de un día para otro, así que es la opción de reserva. ## El móvil tiene que estar en la misma red diff --git a/docs/mapping.md b/docs/mapping.md index 682e3eb..5376940 100644 --- a/docs/mapping.md +++ b/docs/mapping.md @@ -1,50 +1,164 @@ # Projection mapping +> **Pestañas de capa.** Cada capa tiene su propia pestaña arriba, junto a +> MOTORES y MAPPING, y el botón **+ CAPA** crea una y te deja ya dentro de +> ella. Esa vista enseña solo la previsualización y los ajustes de *esa* capa: +> es la forma de trabajar cuando hay varias, sin ir y volver a la lista. +> +> **Visuales de projectM en una capa.** La fuente "Visuales MilkDrop" tiene +> ahora un selector de **biblioteca**: los ~100 presets de Butterchurn, o los +> **9795 `.milk` de projectM** (categoría / visual / variante, o "uno al +> azar"). Los `.milk` se traducen a Butterchurn en el propio navegador al +> elegirlos. Ver `pendiente.md` punto 2 para el detalle y sus límites. + + FOSFENO puede **deformar las visuales** para encajarlas en superficies físicas (una pared en ángulo, cajas, un objeto, varias zonas de una fachada) en vez de proyectar un simple rectángulo. Todo se hace **desde el panel**, arrastrando con el ratón o el dedo, y se **guarda solo**. +Y no es una sola imagen troceada: **cada zona es una capa con su propio +visual**. MilkDrop a la derecha, otro preset distinto a la izquierda y un vídeo +en el centro, cada uno en su sitio y a la vez. Eso está en +[Capas: un visual distinto en cada zona](#capas-un-visual-distinto-en-cada-zona). + Se aplica a los motores que corren en el navegador: **Butterchurn, Hydra, Shaders y Mezclador**. (projectM nativo aún no; ver [Limitaciones](#limitaciones).) +## Dónde está: la pestaña MAPPING + +El mapping tiene **su propia pestaña**, a la derecha del título **FOSFENO**, al +lado de **MOTORES**. Están separados a propósito: MOTORES es el directo +(encendido, audio, motores) y MAPPING es el montaje. Así ni se estorban ni se +confunden. + +En la pestaña MAPPING la tarjeta ocupa el ancho entero, para que la +previsualización sea grande y se puedan arrastrar los puntos con comodidad — +también con el dedo, desde el móvil. + +Mientras el mapping esté activado, la pestaña lleva un **punto verde**: desde +MOTORES se ve de un vistazo que la salida se está deformando. + +
+ La pestaña MAPPING del panel +
+ ## En 4 pasos -1. Elige un motor que **no** sea projectM (Butterchurn, Hydra, Shaders o Mezcla). -2. En el panel, sección **Mapping**, marca **Activar mapping**. -3. Pulsa **+ Superficie**. Aparece un recuadro con las visuales en la - previsualización del panel (y en el proyector). -4. En la **previsualización** del panel, **arrastra los puntos** para encajar la - forma sobre tu superficie real. Se ve en el proyector al instante. +1. Entra en la pestaña **MAPPING** y marca **Activar mapping**. +2. Pulsa **+ Capa**. Aparece un recuadro en la previsualización del panel (y en + el proyector). +3. **Arrastra los puntos** para encajar la forma sobre tu superficie real. Se ve + en el proyector al instante. +4. Con la capa seleccionada, en **Propiedades** eliges **qué se proyecta ahí**. -## La sección Mapping del panel +## La pestaña MAPPING por dentro + +Tiene tres bloques, de arriba abajo: la **salida**, las **capas** y las +**propiedades** de la capa que tengas seleccionada. + +**Salida** - **Activar mapping** — enciende/apaga la deformación. -- **Modo edición** — muestra u oculta los tiradores en la **ventana de las - visuales** (por si prefieres ajustar ahí con un ratón). Para el uso normal no - hace falta: con la previsualización del panel basta. -- **+ Superficie** — añade una superficie nueva (un cuadrilátero). +- **Editar en el escenario** — saca los tiradores y la malla de colores + también sobre las propias visuales (por si prefieres ajustar ahí con un + ratón). Para el uso normal **no hace falta**: con la previsualización del + panel basta, y así el proyector sale limpio. Es un modo de montaje, no un + ajuste: **arranca siempre apagado**, aunque lo dejaras puesto. +- **Previsualización** — el recuadro donde arrastras los puntos (ratón o dedo). + Cada capa lleva escrito su nombre y qué visual tiene dentro. + +**Capas** + +- **+ Capa** — añade una zona nueva. Sale escalonada, para que no tape a la + anterior, y queda seleccionada. - **+ Máscara** — añade una **máscara**: un recuadro **negro** que tapa una zona (útil para recortar el derrame de luz fuera de la superficie física). -- **Reset** — borra todas las superficies y máscaras. -- **Previsualización** — el recuadro donde arrastras los puntos (ratón o dedo). - Muestra una rejilla dentro de cada superficie para que veas cómo se deforma. -- **Lista** — cada superficie y máscara con su tamaño de malla y un botón **✕** - para borrarla. + +> **Capa ≠ máscara.** Una **capa** proyecta algo (eliges qué en Propiedades); +> una **máscara** solo tapa y no lleva visual. Es la confusión más fácil: si +> creaste una máscara buscando dónde poner el visual, selecciónala y pulsa +> **Convertirla en capa** — se sustituye por una capa en el mismo sitio y con +> la misma forma. +- **Reset** — borra todas las capas y máscaras. +- La **lista** se lee como en cualquier mesa de VJ: **la de arriba es la que + queda delante** en el proyector. Cada fila tiene: + + | Botón | Qué hace | + |-------|----------| + | ◉ / ○ | Enciende o apaga la capa sin borrarla | + | ▲ ▼ | La sube o la baja en el orden de pintado | + | ✕ | La borra | + +**Propiedades** — nombre, qué se proyecta, opacidad, mezcla y malla. Se explican +abajo. + +## Capas: un visual distinto en cada zona + +Esto es lo que separa el mapping de FOSFENO de un simple recorte: **cada capa +elige su propia fuente**, así que la proyección puede llevar cosas distintas a +la vez. + +| Fuente | Qué es | Coste | +|--------|--------|-------| +| **El motor de MOTORES** | Lo que esté puesto en la otra pestaña. En varias capas a la vez, todas enseñan lo mismo | ninguno | +| **Visuales MilkDrop propias** | Un Butterchurn **solo para esa capa**, con su preset. Es lo que permite dos looks de MilkDrop distintos a la vez | alto | +| **Shader GLSL** | Uno de los shaders de la librería, reaccionando al audio | medio | +| **Clip o imagen** | Un archivo de `data/videos`. En bucle y sin sonido | bajo | +| **Cámara** | La webcam | bajo | +| **Negro** | Nada: tapa la zona sin usar máscara | ninguno | + +Las capas de MilkDrop y de shader **son motores de verdad funcionando**: cada +una se renderiza aparte y el mapper la estira al deformarla. Los clips y la +cámara, en cambio, **se comparten**: si dos capas usan el mismo clip, se +reproduce una sola vez. + +### La receta del ejemplo + +Tres zonas, tres visuales distintos: + +1. **+ Capa** → nómbrala «Pared izquierda» → fuente **Visuales MilkDrop + propias** → elige un preset. +2. **+ Capa** → «Centro» → fuente **Clip o imagen** → elige tu vídeo. +3. **+ Capa** → «Pared derecha» → fuente **Visuales MilkDrop propias** → *otro* + preset, o marca **Cambiar de preset solo** para que vaya rotando. +4. Arrastra cada una a su sitio en la previsualización. + +
+ Tres capas con visuales distintos +
+ +### Transiciones + +Las capas de **Visuales MilkDrop** llevan su propio deslizador de +**transición (0–8 s)**: es lo que tarda un preset en disolverse en el +siguiente. A 0 el cambio es un corte seco; subiéndolo, uno se funde con el +otro sin que se note. Combinado con **Cambiar de preset solo**, la capa va +alternando visuales sola y en suave. + +El fundido lo hace el propio Butterchurn mientras carga, así que no cuesta +rendimiento aparte. + +### Opacidad y mezcla + +Cuando dos capas se solapan, mandan estos dos ajustes: + +- **Opacidad** — cuánto deja ver lo que hay debajo. +- **Mezcla** — **Normal** tapa lo de debajo; **Sumar** apila luz, así que las + zonas oscuras de la capa dejan pasar la de abajo. Sumar es lo que da los + solapes bonitos entre visuales; normal es lo que quieres cuando cada capa va + en su pared y no se tocan. ## Superficies con malla (curvar) -Una superficie nueva es un cuadrilátero de 4 esquinas (corrección de -perspectiva / *keystone*). Para superficies **curvas**: +Una capa nueva es un cuadrilátero de 4 esquinas (corrección de perspectiva / +*keystone*). Para superficies **curvas**: -1. Selecciona la superficie (toca dentro de ella en la previsualización). -2. Aparece **− malla / + malla**: cada **+ malla** la subdivide (2×2, 3×3… - hasta 6×6). +1. Selecciona la capa (toca dentro de ella en la previsualización). +2. En Propiedades, **Malla**: cada **+** la subdivide (2×2, 3×3… hasta 6×6). 3. Ahora puedes arrastrar **cualquier punto** de la rejilla (no solo las esquinas) para curvarla y adaptarla a superficies no planas. -Puedes crear **varias superficies** y mapear la misma imagen en distintas zonas. - ## Máscaras Una máscara es un cuadrilátero **negro** que se pinta encima de todo. Sirve para @@ -60,18 +174,39 @@ cero, usa **Reset** o borra ese archivo. ## Editar en la ventana de las visuales (opcional) -Con **Modo edición** activo, los tiradores también aparecen sobre las propias -visuales. Ahí, además, funcionan atajos de teclado: +Con **Editar en el escenario** activo, los tiradores también aparecen sobre las +propias visuales, con el nombre y la fuente de cada capa escritos encima. Ahí, +además, funcionan atajos de teclado: -- **doble clic** en un hueco: crea una superficie nueva. -- **+ / −**: subdivide/reduce la malla de la superficie seleccionada. -- **Supr**: borra la superficie o máscara seleccionada. +- **doble clic** en un hueco: crea una capa nueva. +- **+ / −**: subdivide/reduce la malla de la capa seleccionada. +- **Supr**: borra la capa o máscara seleccionada. + +## Cuánto aguanta + +Cada capa de **MilkDrop** o de **shader** es un motor de verdad renderizando +aparte, así que tienen un coste real: + +| Equipo | Capas de MilkDrop / shader | Capas de clip o cámara | +|--------|---------------------------|------------------------| +| Portátil con GPU | 3–4 sin despeinarse | muchas | +| Raspberry Pi 5 | 2, con margen justo | varias | +| Raspberry Pi 4 | 1 | unas pocas | + +Cada capa se renderiza a **640×360** y el mapper la estira al deformarla: en una +zona de la proyección no se nota, y es lo que hace viable tener varias a la vez. +Si va lenta, lo primero que hay que quitar son las capas de MilkDrop de más; +clips y cámara cuestan mucho menos, y además **se comparten** entre capas. ## Limitaciones -- **projectM nativo no se mapea todavía.** projectM corre en su propia ventana - fuera del navegador, así que el mapper (que trabaja sobre las visuales web) no - lo alcanza. Para mapear el mismo tipo de gráficos, usa **Butterchurn** - (MilkDrop en el navegador). El soporte de projectM está en estudio. -- El mapping es una capa de deformación sobre la imagen final; no cambia el - contenido de cada motor. +- **projectM nativo no puede ir en una capa.** projectM corre en su propia + ventana fuera del navegador, así que el mapper no lo alcanza. Para el mismo + tipo de visual, usa la fuente **Visuales MilkDrop propias**: son los mismos + presets de MilkDrop, dentro del navegador, y además puedes tener varios + distintos a la vez. Meter la ventana nativa como capa (capturándola) está en + estudio. +- El mapping deforma y compone; no cambia el contenido de cada motor. + +Cómo está montado por dentro (el compositor WebGL, las capas, el formato de las +superficies): [Arquitectura](arquitectura.md#mapping-y-capas). diff --git a/docs/mezclador.md b/docs/mezclador.md new file mode 100644 index 0000000..50eca27 --- /dev/null +++ b/docs/mezclador.md @@ -0,0 +1,182 @@ +# El Mezclador VJ (mezclas) + +El **Mezclador** es el motor de vídeo de FOSFENO. No es un motor de visuales +generativas como los otros: es una **mesa de mezclas de dos capas** que combina +en directo una capa de fondo con un clip, y les aplica efectos de color. + +Se elige con el botón redondo **Mezcla** del apartado *Motor de visuales*, en la +pestaña **MOTORES**. + +## El modelo mental: siempre dos capas + +Todo el mezclador se entiende con esta idea. Hay **dos capas y solo dos**, y +siempre son las mismas: + +``` + ┌──────────────────────────────────────────────┐ + │ CAPA DE ENCIMA :: el CLIP │ un vídeo o una imagen + │ (data/videos/…) │ de la galería + ├──────────────────────────────────────────────┤ + │ CAPA DE ABAJO :: el FONDO │ la cámara web + │ (cámara o visuales Butter / MilkDrop) │ o las visuales Butter + └──────────────────────────────────────────────┘ + ▼ + [ modo de mezcla ] → proyector +``` + +La tarjeta del panel está numerada en ese mismo orden, y ese orden es la +respuesta a casi cualquier duda: + +| Paso | Qué decide | +|------|------------| +| **1 · Qué se ve** | si sale solo el fondo, solo el clip, o los dos | +| **2 · Fondo** | qué hay en la capa de abajo: **cámara** o **visuales Butter** | +| **3 · Clip** | qué vídeo o imagen va en la capa de encima | +| **4 · Cómo se juntan** | el modo de mezcla (solo aparece si se ven los dos) | + +Debajo del paso 4 hay una **línea naranja de resumen** que dice, en una frase, +lo que va a salir por el proyector con los ajustes que tengas puestos. Si en +algún momento no sabes qué estás montando, esa línea lo dice. + +
+ La tarjeta del Mezclador VJ +
+ +## Poner visuales de fondo (MilkDrop detrás del vídeo) + +Esto es lo que suele costar encontrar, así que va aparte. **Sí está integrado** +y funciona así: + +1. Motor **Mezcla**. +2. Paso 2, desplegable **Fondo (capa de abajo)** → **Visuales Butter + (MilkDrop)**. +3. La cámara se apaga sola (piloto incluido) y su sitio en la capa de abajo lo + ocupan las visuales de MilkDrop. +4. Justo debajo aparece el **preset de MilkDrop** que hace de fondo, con + **« Anterior / Siguiente »** y la casilla **Cambiar el fondo solo**. + +Ese preset es **el mismo del motor Butter**: lo que elijas aquí queda elegido +también allí, y al revés. El cambio automático también es el mismo (los +segundos o los compases se ajustan en la tarjeta del motor Butter). + +> **projectM no puede ir de fondo.** projectM es un programa nativo que corre +> en su propia ventana, fuera del navegador, así que el mezclador no puede +> leerlo como capa. El fondo de visuales es siempre Butterchurn — que es el +> mismo MilkDrop, con los mismos presets, dentro del navegador. + +## Receta: un personaje recortado sobre las visuales + +Es el uso estrella del mezclador y encadena todo lo anterior: + +1. **1 · Qué se ve** → **Los dos**. +2. **2 · Fondo** → **Visuales Butter (MilkDrop)**, y elige ahí el preset. +3. **3 · Clip** → un clip de silueta sobre negro (los `_silueta-negro` que + genera DETEKTION, el proyecto hermano de `VISUALES/`, o cualquier vídeo con + fondo negro). +4. **4 · Cómo se juntan** → **Recorte (personaje delante)**. + +El negro del clip se vuelve transparente y la figura queda **delante** de +MilkDrop, latiendo con la música. En este modo el primer deslizador deja de ser +la mezcla A/B y pasa a llamarse **Umbral del recorte**: súbelo si queda un halo +oscuro alrededor de la figura, bájalo si se está comiendo partes del personaje. + +## Paso 1 · Qué se ve + +- **Solo el fondo** — a pantalla completa. El clip no se usa. +- **Solo el clip** — a pantalla completa. El fondo no se usa (ni la cámara ni + las visuales Butter: si eliges esto, el fondo queda atenuado en el panel). +- **Los dos** — se combinan según el paso 4. + +Los controles que en ese momento no pintan nada **no desaparecen: se atenúan**, +para que se vea que existen sin que despisten. + +## Paso 2 · Fondo (capa de abajo) + +**Cámara.** Con la casilla *Cámara activada* y, si tienes más de una, el +desplegable para elegirla. Si la cámara falla, el aviso sale en la banda de +arriba del panel; para reintentar, apaga y enciende la casilla. + +**Visuales Butter (MilkDrop).** Lo explicado más arriba. La cámara se libera de +verdad mientras tanto. + +## Paso 3 · Clip (capa de encima) + +Los clips salen de la carpeta `data/videos/`. Hay dos formas de meterlos: + +- **Copiándolos a la carpeta** y pulsando **Actualizar lista**. +- **Con el botón Subir vídeo o imagen**, desde el móvil o el ordenador, sin + tocar carpetas. + +Formatos que van bien: + +| Tipo | Formatos | Nota | +|------|----------|------| +| Vídeo | `.mp4` (H.264), `.webm` | En Raspberry, 720p o menos | +| Imagen | `.jpg`, `.png`, `.gif`, `.webp` | Se queda fija como capa | + +Los vídeos se reproducen **en bucle y sin sonido** (FOSFENO escucha la música de +la sala, no la del clip). + +## Paso 4 · Cómo se juntan + +Solo aparece con **Los dos**. + +| Modo | Qué hace | +|------|----------| +| **Recorte** | El negro del clip se vuelve transparente: la figura queda **delante** del fondo. El deslizador pasa a ser el umbral | +| **Fundido** | Disuelve una capa sobre la otra según el deslizador de mezcla | +| **Diferencia** | Resta las dos capas: contornos y colores invertidos donde coinciden | +| **Multiplicar** | Oscurece: solo sobrevive lo que es claro en las dos | +| **Sumar** | Aclara: suma la luz de las dos capas | +| **Capa** | Superpone el clip usando su canal alfa | + +## Los efectos de color + +Se aplican **al resultado ya mezclado**, en este orden (importa: el +caleidoscopio deforma antes de que el color entre en juego): + +``` +caleidoscopio → rotación → pixelado → tono → saturación → contraste → +brillo → colorama → posterizar → invertir → pulso al ritmo → feedback +``` + +- **Mezcla A/B** — cuánta capa de encima frente a la de abajo (o el umbral, en + modo Recorte). +- **Tono / Saturación / Contraste / Brillo** — corrección de color de toda la + vida. +- **Colorama** — recicla los colores; en valores altos psicodelia pura. +- **Posterizar** — reduce el número de colores por franjas. +- **Pixelado** — baja la resolución aparente en bloques. +- **Caleidoscopio** — repite la imagen en N sectores en espejo. +- **Rotación** — gira la imagen. +- **Feedback** — realimenta el fotograma anterior: estelas y túneles. Con + valores altos la imagen tarda en limpiarse. +- **Invertir colores** — el negativo. +- **Pulso al ritmo** — la imagen late con los graves detectados (usa el mismo + análisis de audio que alimenta el BPM). + +## Por debajo + +El mezclador **no es un motor aparte**: genera código [Hydra](https://hydra.ojack.xyz/) +a partir de los controles y lo ejecuta. La cámara entra como fuente `s0` (o el +lienzo de Butterchurn, si el fondo son visuales) y el clip como `s1`. Por eso +el mezclador y el motor Hydra comparten el mismo lienzo y nunca están activos a +la vez. + +Como todo lo que corre en el navegador, la salida del mezclador **se puede +mapear**: ver [Projection mapping](mapping.md). + +## Si algo no se ve + +FOSFENO no se queda callado: el motivo sale en la banda de avisos, arriba del +panel. + +| Aviso | Qué hacer | +|-------|-----------| +| «La cámara está apagada» | Marca *Cámara activada* en el paso 2 | +| «No hay vídeo elegido» | Elige uno en el paso 3 | +| «No hay vídeos» | Sube uno, o copia archivos a `data/videos` y pulsa *Actualizar lista* | +| «No se pudo activar la cámara» | Comprueba el cable, pulsa *Buscar dispositivos de nuevo* y apaga/enciende la casilla | +| «No se pudo cargar …» | El formato no lo traga el navegador: pásalo a `.mp4` (H.264) o `.webm` | + +Más casos en [Solución de problemas](problemas.md). diff --git a/docs/pendiente.md b/docs/pendiente.md new file mode 100644 index 0000000..d423c1e --- /dev/null +++ b/docs/pendiente.md @@ -0,0 +1,162 @@ +# Pendiente + +Lo que sabemos que falta o falla, con el diagnóstico ya hecho para no volver a +investigarlo desde cero. Ordenado por lo que más duele. + +--- + +## 1. Las capas no son de verdad independientes + +**Lo que pasa.** Se pueden poner dos capas con visuales MilkDrop, pero acaban +siendo **la misma imagen**. Y no hay forma de decir "en esta zona el Mezclador, +en esta otra Butter", configurando cada una por su cuenta. + +Son **tres cosas distintas** mezcladas en el mismo síntoma: + +### 1a. Dos capas Butter sin preset elegido salen idénticas — ✅ HECHO + +`web/stage/layers.js`, en `crearButter()`, hacía: + +```js +let i = Math.max(0, nombres.indexOf(f.preset)); +``` + +Con `preset: ""` (lo que quedaba si la lista de presets aún no había llegado +al panel al crear la capa), `indexOf("")` devuelve `-1` y el `Math.max(0, -1)` +lo dejaba en **0**: todas las capas así arrancaban en el mismo preset. + +**Cómo quedó:** sin preset elegido, cada instancia coge uno **al azar** y lo +devuelve en `presetElegido`. `sync()` avisa por `deps.onPreset(id, preset)`, +que `stage.js` (`anotarPresetDeCapa`) escribe en el estado con `set_mapping`, +así que el panel enseña cuál le tocó en vez de dejar el desplegable en blanco. +No recrea la instancia: la firma de una capa butter sigue siendo el tipo a +secas. + +### 1b. Las capas puestas en "El motor de MOTORES" comparten imagen — ✅ HECHO + +Esa fuente es, literalmente, el lienzo del motor activo. Dos capas así van a +enseñar lo mismo siempre — no es un fallo, es lo que significa. El problema era +que **era la fuente por defecto** de toda capa nueva, así que el primer +resultado que veía cualquiera era "dos capas iguales". + +**Cómo quedó:** una capa nueva ya no nace en "motor". `panel.js` tiene +`fuenteDeCapaNueva()`, que la estrena en MilkDrop con preset al azar +(contenido distinto desde el primer clic) y solo cae en "motor" si Butterchurn +todavía no ha dado su lista de presets. Lo usan tanto `+ Capa` como +`Máscara → Capa`. + +### 1c. Falta el Mezclador (y Hydra) como fuente de capa — *(el trabajo de verdad)* + +Hoy las fuentes son: motor, butter, shader, clip, cámara, negro. **No está el +Mezclador**, así que "una zona con el Mezclador y otra con Butter" solo se +puede hacer vía "motor", y entonces manda el selector global. + +Por qué no está: el Mezclador **es** Hydra (genera código Hydra y lo ejecuta), y +Hydra está montado como instancia única global (`makeGlobal: true`, con `s0`, +`s1`, `o0` en el espacio global). Para tener dos mezcladores independientes hay +que instanciar Hydra varias veces con `makeGlobal: false` y usar la API por +instancia (`h.synth.src(...)`), lo que obliga a reescribir `buildMixerCode` para +que no dependa de los nombres globales. + +**Coste realista:** medio día, más el gasto de GPU de un Hydra por capa. Antes +de meterse, decidir si compensa frente a la alternativa barata: dejar el +Mezclador solo como motor a pantalla completa y que las capas tiren de +MilkDrop/shader/clip, que es lo que ya funciona. + +### 1d. Los dos paneles no están relacionados + +MOTORES y MAPPING van cada uno por su lado: el preset que eliges en el motor +Butter y el de una capa MilkDrop son listas distintas, no comparten favoritos, +y lo que tocas en uno no se refleja en el otro. + +**Hacia dónde:** una sola biblioteca de presets y una sola lista de favoritos, +usada por el motor Butter, por las capas del mapping y por projectM. Esto se +solapa con el punto 2: es el mismo trabajo de unificación. + +--- + +## 2. projectM en el mapper — ✅ HECHO (camino A) + +**Lo que pasaba.** Al elegir projectM no había forma de mapearlo ni de usarlo +en una capa, y ahí está **la biblioteca de verdad**: 9795 presets `.milk` en 11 +categorías y 189 visuales. Butterchurn solo trae ~100 propios. + +**Por qué no se podía.** projectM es un proceso nativo que pinta en su propia +ventana X11/Wayland. El compositor de mapping es WebGL dentro del navegador y +solo puede usar como textura lo que vive en la página. + +**Cómo quedó.** Se descartó capturar la ventana (frágil en Wayland) y compilar +projectM a WebAssembly (un proyecto en sí). Se hizo el **camino A**: traducir +los `.milk` a presets de Butterchurn **en el propio navegador**, al elegirlos. + +- `web/lib/milkdrop-preset-converter.min.js` (npm `milkdrop-preset-converter`, + añadido a `web/package.json` y a `install.sh`). +- `layers.js` → `cargarMilk(ruta)`: pide el `.milk` al backend (ya los servía), + lo convierte y se lo pasa a `viz.loadPreset()`. Cachea **la promesa**, no el + resultado, para que dos capas pidiendo el mismo preset lo conviertan una vez. +- La capa MilkDrop tiene ahora `biblioteca: "butter" | "projectm"`. La firma de + la capa **sigue siendo `'butter'`**, así que cambiar de biblioteca no recrea + la instancia ni gasta un contexto WebGL más. +- El panel tiene selector de biblioteca y, con projectM, los tres niveles + (categoría / visual / variante) más un botón de "uno al azar". + +**Medido, no supuesto:** + +- **100/100** presets de una muestra repartida por toda la biblioteca se + convierten sin excepción, incluidos **84/84** de los que llevan shaders + `warp_1`/`comp_1` (que eran justo los dudosos). +- Conversión de un `.milk` real en el navegador: **7 ms**. +- Una capa del mapper con `Dancer/Glowsticks/285.milk` renderiza sin un solo + aviso del escenario. + +> Cuidado al medir esto: si el `.milk` no se descarga, Butterchurn **sigue +> pintando su preset de reserva**. Una prueba que solo mire "¿hay píxeles +> encendidos?" da OK igual. Por eso la comprobación exige además que el +> escenario no haya soltado ningún aviso. La primera versión de la prueba dio +> un falso OK exactamente por esto. + +**Lo que queda de este punto:** la conversión no es perfecta al 100 % en +fidelidad — algún shader complejo puede verse distinto del original en +projectM. Se convierte y se ve, pero si un preset no convence, la salida es +probar otra variante. Y falta unificar los favoritos entre el motor projectM +y las capas (parte del punto 1d). + +--- + +## 3. Los clips salían del revés en el mapping — ✅ ARREGLADO + +**Lo que pasaba.** Al poner un vídeo en una capa del mapping, salía volteado +verticalmente (boca abajo). + +**Por qué.** `web/stage/mapper.js`, en `initGL()`, hacía +`gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)`. Pero el modelo del mapper va +con el origen **arriba-izquierda** (`vUV.y = 0` es el borde de arriba de la +superficie) y `texImage2D` ya sube la primera fila de la imagen en `t = 0`. +Con el flip puesto, `t = 0` pasaba a ser la ÚLTIMA fila: todo del revés. +Se notaba con los clips porque una visual de MilkDrop es casi simétrica y no +canta. + +**Comprobado**, no deducido: con una fuente mitad roja arriba / mitad azul +abajo, renderizando el `mapper.js` real en Chromium headless y leyendo el +píxel de arriba con `readPixels`, antes salía AZUL y ahora sale ROJO. + +De paso se midió el **Mezclador** (Hydra, `s1.init({src})`) con la misma +prueba: ese sale bien, no hay que tocarlo. Si alguna vez se ve del revés ahí, +no es este fallo. + +> Al medir esto, ojo con `readPixels` sobre un lienzo sin +> `preserveDrawingBuffer`: devuelve negro y es facilísimo leerlo como "está +> volteado". Hay que reservar el contexto con `preserveDrawingBuffer: true` +> ANTES de que la librería llame a `getContext`. + +--- + +## 4. Menor: no hay fundido al cambiar de motor + +Pasar de Mezcla a Butter es un corte seco. Dentro de una capa MilkDrop sí hay +transición suave (deslizador de 0–8 s), pero cruzar **dos motores** exige +renderizar los dos a la vez durante el cruce y fundirlos en el compositor. + +Se puede hacer en el mapper (dibujar la capa dos veces, con la fuente vieja +bajando de alfa y la nueva subiendo), pero cuesta tener los dos motores vivos a +la vez unos segundos. Decidir si merece la pena. diff --git a/docs/seguridad.md b/docs/seguridad.md new file mode 100644 index 0000000..c2e9b25 --- /dev/null +++ b/docs/seguridad.md @@ -0,0 +1,94 @@ +# Seguridad + +FOSFENO es un **aparato de directo**, no un servicio en internet. Está pensado +para una red local: tú, tu móvil y el proyector. Esta página cuenta qué asume +el diseño, qué pasa si la red no es de fiar, y cómo cerrarlo. + +## El modelo de amenaza, en una frase + +Cualquiera que alcance `http:///` **manda sobre las visuales**. +Por defecto no hay contraseña: es lo cómodo para casa, un ensayo o una red +tuya, y es como funcionó desde el principio. + +Eso está bien en tu salón. En **la wifi de un bar o de un festival** significa +que un desconocido puede: + +- Apagar o reiniciar el equipo en mitad de la sesión. +- Cambiar de motor, de preset y de mapping. +- **Ejecutar código** en el escenario, porque el editor de Hydra/GLSL es + precisamente eso, y el navegador del kiosko arranca con permiso automático de + **cámara y micrófono**. +- Llenarte la tarjeta subiendo archivos a la galería. + +No es un fallo escondido: es la consecuencia de no pedir credenciales. La +solución está abajo y son dos líneas de configuración. + +## La clave compartida + +En `config.json`: + +```json +"auth": { + "token": "loquesea-largo-y-tuyo" +} +``` + +O sin tocar el archivo, por variable de entorno: + +```bash +FOSFENO_TOKEN=loquesea-largo-y-tuyo ./fosfeno +``` + +Con la clave puesta: + +- **El código QR del proyector ya la lleva dentro.** Escaneas y entras, igual + que siempre. No hay que teclear nada. +- Quien solo conozca la IP **no se conecta**: el WebSocket rechaza la conexión, + así que no puede ni mirar el estado ni mandar órdenes. +- Las subidas a la galería también la piden. +- El escenario la recibe del propio servidor al abrirse. No hay nada que + configurar en el navegador. + +La contrapartida: si escribes la dirección a mano tendrás que añadir +`?k=tu-clave` al final. Por eso el QR es el camino cómodo. + +> Con `token` vacío (el valor de fábrica) todo funciona exactamente como antes. +> Actívala cuando pinches fuera de casa. + +## Lo que ya está cerrado + +Estas no dependen de que actives nada: + +| | | +|---|---| +| **Path traversal** | Las rutas de archivos (`/panel`, `/stage`, `/lib`, `/data`, `/pm/variantes`) resuelven y comprueban que no salen de su carpeta. Probado con `../`, `%2e%2e%2f`, doble codificación y rutas absolutas | +| **Nombres de archivo subidos** | Pasan por `secure_filename` y una lista blanca de extensiones. No se puede escribir fuera de `data/videos` ni subir un `.sh` | +| **Inyección de comandos** | Ningún `shell=True`, ningún `os.system`. Todos los procesos se lanzan con lista de argumentos | +| **Rutas de presets de projectM** | La categoría y el visual vienen del panel, así que se validan contra la carpeta de presets antes de usarse | +| **XSS en el panel** | Los nombres de clips, capas y favoritos se pintan con `textContent`, nunca con `innerHTML` | +| **CORS** | El WebSocket solo acepta su **mismo origen**. Antes admitía cualquiera, así que una web abierta en el móvil de alguien de la sala podía mandar órdenes | +| **Tamaño de subida** | 512 MB por archivo (antes 4 GB, suficiente para llenar una microSD en tres peticiones) | +| **Payloads del WebSocket** | Los ajustes se filtran por lista blanca de claves y las capas del mapping se sanean antes de guardarse, así que no se puede meter basura en el estado ni en `data/mapping.json` | + +## Lo que NO deberías hacer + +- **No lo expongas a internet.** Nada de abrir el puerto 80 en el router ni + ponerle un túnel. No está pensado para eso y la clave compartida no es + autenticación seria. +- **No dejes el apagado remoto a mano de una red pública** sin clave. El + instalador da permiso `sudo` sin contraseña para `reboot` y `poweroff` + (`install.sh`), que es lo que hace que el botón del panel funcione. +- **No pongas datos personales en `data/videos`.** Cualquiera que entre al + panel puede listarlos y reproducirlos. + +## Lo que no se sube al repositorio + +`.gitignore` deja fuera lo de cada instalación: `.venv/`, `*.log` (el log del +servidor lleva IPs), `data/mapping.json`, `data/pm-favoritos.json`, +`data/videos/*` y `data/presets-projectm/`. En el repo solo van código, +documentación y los presets de fábrica. + +## Si encuentras algo + +Es un proyecto pequeño de un hacklab: abre una incidencia en el Gitea del +proyecto contando qué has visto y cómo reproducirlo. diff --git a/docs/uso.md b/docs/uso.md index 213acde..aaf3de7 100644 --- a/docs/uso.md +++ b/docs/uso.md @@ -18,6 +18,25 @@ conectado con la Raspberry. Rojo quiere decir que se ha perdido la conexión. ![Panel de control de FOSFENO](assets/panel.png) +## Las dos pestañas: MOTORES y MAPPING + +A la derecha del título **FOSFENO** hay dos pestañas. Separan las dos cosas que +se hacen con el panel, que no tienen nada que ver entre sí: + +- **MOTORES** — el directo. Encendido, audio, sensibilidad, el motor de + visuales y sus controles. Es la pestaña de siempre y la que está abierta al + arrancar. +- **MAPPING** — el montaje. Encajar la imagen sobre la superficie física: + superficies, malla y máscaras, con una previsualización grande para arrastrar + los puntos. + +Lo normal es pasar por MAPPING una vez al montar y quedarse en MOTORES el resto +de la noche. Si el mapping está activado, aparece un **punto verde** en su +pestaña, para que se sepa que la salida se está deformando aunque estés mirando +MOTORES. + +El panel recuerda en qué pestaña lo dejaste. + ## Avisos y errores Justo debajo de la cabecera aparecen los avisos. Si algo va mal (la cámara no @@ -56,9 +75,10 @@ librería de fragmentos listos para usar; eliges uno y se carga en el editor. de código. Los shaders reciben información del audio y del ritmo, así que se mueven con la música. -**Mezclador.** El modo de vídeo. Mezcla la imagen de una webcam USB con clips -de vídeo y efectos de color. Es lo más parecido a un programa de VJ como -Resolume, pero funcionando dentro de la Raspberry. +**Mezclador.** El modo de vídeo. Monta dos capas —un fondo (la webcam o las +propias visuales de MilkDrop) y un clip encima— y las combina con efectos de +color. Es lo más parecido a un programa de VJ como Resolume, pero funcionando +dentro de la Raspberry. ## Audio y BPM @@ -92,15 +112,23 @@ código es GLSL y tienes los uniforms `u_time`, `u_bass`, `u_mid`, `u_treble`, ## El modo Mezclador -Primero copia tus clips de vídeo en la carpeta `data/videos` del proyecto. -Aparecen solos en el desplegable de vídeo del panel. Para que vayan finos en -la Raspberry conviene que sean clips cortos, en 720p o menos y en H.264. +El mezclador monta **dos capas**: un **fondo** (la cámara web, o las visuales +Butter/MilkDrop) y un **clip** encima (un vídeo o una imagen de la galería). La +tarjeta del panel va numerada en ese orden: qué se ve, el fondo, el clip y cómo +se juntan. Debajo hay una línea de resumen que dice, en una frase, lo que va a +salir por el proyector. -En el panel eliges la fuente: solo la cámara, solo el vídeo, o la mezcla de -las dos. Debajo tienes el modo de mezcla y una fila de controles de color: -tono, saturación, contraste, brillo, colorama, posterizado, pixelado, -caleidoscopio, rotación y feedback. La casilla de pulso al ritmo hace que la -imagen lata con los graves. +Para **poner visuales de fondo**, en el paso 2 cambia el desplegable de *Cámara* +a **Visuales Butter (MilkDrop)**: la cámara se apaga y su sitio lo ocupan las +visuales, con su propio selector de preset ahí mismo. Combinado con un clip de +silueta y el modo *Recorte*, el personaje queda delante de MilkDrop. + +Los clips se copian a `data/videos` o se suben desde el propio panel con el +botón *Subir vídeo o imagen*. Para que vayan finos en la Raspberry conviene que +sean cortos, en 720p o menos y en H.264. + +La guía completa, con los modos de mezcla, los efectos de color y las recetas: +[El Mezclador VJ](mezclador.md). ## Apagar y reiniciar diff --git a/docs/visuales.md b/docs/visuales.md new file mode 100644 index 0000000..a18c3c8 --- /dev/null +++ b/docs/visuales.md @@ -0,0 +1,160 @@ +# La galería de visuales + +Los clips que se ven en el Mezclador y en las capas del mapping viven en +`data/videos`. Además del archivo suelto, hay un **catálogo** +(`data/visuales.json`) con la ficha de cada uno: de qué película sale, quién +aparece, cuánto dura y — lo importante — **si es una silueta de verdad y cómo +de llena está**. + +Sin esa ficha el panel solo puede enseñar `p3-davy-jones-tormenta_silueta-negro.mp4`. +Con ella enseña *Davy Jones (en la tormenta)*, agrupado bajo *Piratas del +Caribe: En el fin del mundo*, y marca los que casi no tienen figura. + +--- + +## Cómo se genera + +```bash +cd ~/COFRE/CODERS/MUSIKA/VISUALES/FOSFENO + +python3 scripts/catalogar-visuales.py # cataloga y escribe el JSON +python3 scripts/catalogar-visuales.py --limpiar # + aparta los que no son siluetas +``` + +Necesita `ffmpeg`/`ffprobe` y `numpy`. Tarda unos segundos por clip: decodifica +2 fotogramas por segundo a 160x90 en gris y saca las cuentas de ahí. + +Hay que volver a pasarlo **cada vez que entran clips nuevos** en la galería. El +panel lo recarga solo al pulsar el botón de recargar del Mezclador, o al subir +un archivo; si el JSON no está, el panel sigue funcionando con los nombres de +archivo pelados. + +## Qué mide, y por qué así + +**¿Es una silueta o es un trozo de película tal cual?** En un clip siluetado +por DETEKTION el fondo es negro **puro** (valor 0), cosa que no pasa nunca en +una imagen de cine, por oscura que sea. Así que basta con medir qué porcentaje +del cuadro está a cero: + +| | negro puro | +|---|---| +| Clips siluetados de la galería | 0,63 – 0,90 | +| Cortes sin siluetear | 0,04 – 0,06 | + +No hay zona gris, y por eso el umbral está en 0,30. No es un número inventado: +sale de medir la galería entera. + +**¿Tiene figura suficiente?** Un clip puede estar perfectamente siluetado y aun +así no servir, porque la escena era muy oscura y el recorte salió casi vacío. +Se mide el porcentaje del cuadro por encima de gris 32, y se mira la **mediana** +(no un fotograma suelto: casi todos los clips tienen tramos vacíos y tramos +llenos, y juzgar por el fotograma central engaña). + +| calidad | criterio | qué significa | +|---|---|---| +| `buena` | mediana ≥ 0,03 | se ve bien sobre el fondo | +| `floja` | mediana < 0,03 | figura pequeña o muy oscura | +| `vacia` | mediana < 0,005 o más de la mitad de fotogramas vacíos | apenas hay nada | +| `descartable` | negro puro < 0,30 | no es una silueta: se ve el fondo | + +## Qué hace `--limpiar` + +Los `descartable` (los que enseñan el fondo entero de la película) se **mueven** +a `data/videos/_descartados/`. No se borran: si un día quieres volver a pasarlos +por DETEKTION, siguen ahí. El servidor no los sirve, porque solo lista archivos +sueltos de `data/videos`, no subcarpetas. + +Los `floja` y `vacia` **no se tocan**: son siluetas correctas, solo que oscuras. +Se quedan en la galería marcadas, y ya decides tú si las usas — sobre un fondo +de MilkDrop brillante, una silueta tenue puede funcionar. + +## La ficha + +```json +{ + "archivo": "p3-davy-jones-tormenta_silueta-negro.mp4", + "obra": "Piratas del Caribe: En el fin del mundo", + "anio": 2007, + "titulo": "Davy Jones (en la tormenta)", + "tipo": "silueta", + "modo": "silueta sobre negro", + "origen": "detektion", + "personajes": ["Davy Jones"], + "etiquetas": ["en la tormenta"], + "ancho": 1152, "alto": 480, "fps": 23.98, "duracion": 30.03, + "metricas": { "negroPuro": 0.737, "figura": 0.0582, + "figuraPico": 0.1974, "brillo": 8.49, "vacios": 0.03 }, + "clase": "silueta", "calidad": "buena", "aviso": "", + "miniatura": "miniaturas/p3-davy-jones-tormenta_silueta-negro.jpg" +} +``` + +La miniatura no es un fotograma cualquiera: es **el fotograma con más figura** +de todo el clip, que es el que dice de verdad qué vas a ver. + +### De dónde salen los nombres + +El catálogo lee las dos convenciones de DETEKTION: + +- `siluetear.py` → `_silueta-.mp4` → `tipo: silueta` +- `recortar.py` → `_13m26s.mp4` → `tipo: corte` (crudo, **sin** siluetear) + +y del resto del nombre saca la película (`p1`…`p5`, `hackers`, `tron`…), los +personajes conocidos y las palabras de escena. Las tablas están arriba del +todo en `scripts/catalogar-visuales.py`: **para añadir películas o personajes +nuevos se tocan ahí y ya**. + +Un clip que no encaje en ninguna tabla no rompe nada: sale con el nombre +prettificado y agrupado en "Sin catalogar". + +## Transparencia de verdad (canal alfa) + +Hay **dos formas** de que un personaje se vea sobre las visuales, y conviene +no confundirlas: + +**1. Recorte por luminancia (lo de siempre).** El clip `_silueta-negro.mp4` +tiene fondo negro opaco, y es el Mezclador el que lo hace transparente al +vuelo: `src(s0).layer(src(s1).luma(umbral, 0.15))`. Funciona y va en H.264, +que es lo que mejor decodifica una Raspberry. Pega: hay un umbral que ajustar, +y las zonas oscuras del propio personaje (pelo, ropa negra) se comen con él. + +**2. Canal alfa de verdad (`_silueta-alfa.webm`).** El clip lleva la +transparencia dentro, en un WebM con VP9. No hay umbral, no hay bordes +comidos: los píxeles del fondo simplemente **no existen**, y debajo se ve la +capa que haya. Se genera con: + +```bash +.venv/bin/python3 siluetear.py clip.mp4 --modo alfa +``` + +FOSFENO ya lo compone bien sin tocar nada: el fragment shader del mapper +saca `gl_FragColor = vec4(c.rgb, c.a * uAlpha)` y mezcla con +`SRC_ALPHA / ONE_MINUS_SRC_ALPHA`, así que una capa con alfa deja ver la capa +dibujada antes que ella. **Comprobado**, no supuesto: con una capa de fondo +roja y encima un clip alfa, el 48,6 % de la pantalla sale roja por los huecos. + +> Para que se vea a través hace falta que **haya algo debajo**: otra capa del +> mapping dibujada antes (MilkDrop, shader, cámara). Si el clip alfa es la +> única capa, debajo solo está el negro del compositor. + +### Dos trampas del alfa en WebM + +- **El decodificador VP9 nativo de ffmpeg se come el alfa en silencio**: no + falla, devuelve el vídeo entero opaco. Para leerlo hay que pedir + `-c:v libvpx-vp9` a mano. Es exactamente por esto que el catalogador mide + estos clips con `ffmpeg -c:v libvpx-vp9 ... -vf format=rgba,alphaextract` + (y el `format=rgba` no es adorno: sin él `alphaextract` no negocia formato + y ffmpeg aborta). +- **VP9 no tiene decodificación por hardware en la Raspberry Pi 5**, que sí la + tiene para H.264/HEVC. En el portátil va sobrado; en la Pi, con clips de + 480p cortos debería ir, pero **hay que probarlo antes de un bolo**. Si se + atraganta, el camino 1 (negro + luma key) sigue ahí. + +## En el panel + +`server.py` carga el catálogo en `state.meta.visuales` (`{archivo: ficha}`), +filtrado contra lo que hay de verdad en disco para que un catálogo viejo no +enseñe clips borrados. El panel lo usa en `fillClipSelect()`: las listas de +clips salen agrupadas por película con ``, con el título legible, la +duración y la marca de calidad (`· poca figura`, `· casi vacio`), y el aviso +completo en el `title` de cada opción. diff --git a/install.sh b/install.sh index 0967f98..91d7e1c 100755 --- a/install.sh +++ b/install.sh @@ -261,6 +261,10 @@ find_copy() { # find_copy } find_copy butterchurn butterchurn.min.js lib/butterchurn.min.js "Butterchurn" find_copy butterchurn-presets butterchurnPresets.min.js lib/butterchurn-presets.min.js "Presets de Butterchurn" +# Traduce los .milk de projectM a presets de Butterchurn: es lo que permite +# usar la biblioteca grande (9795 presets) dentro de una capa del mapping. +find_copy milkdrop-preset-converter milkdrop-preset-converter.min.js \ + lib/milkdrop-preset-converter.min.js "Conversor de presets .milk" copy_lib node_modules/socket.io-client/dist/socket.io.min.js \ lib/socket.io.min.js "Socket.IO" copy_lib node_modules/qrcode-generator/qrcode.js \ diff --git a/scripts/catalogar-visuales.py b/scripts/catalogar-visuales.py new file mode 100755 index 0000000..d25d5df --- /dev/null +++ b/scripts/catalogar-visuales.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +""" +FOSFENO :: Catalogo de visuales +========================================================================== +Recorre data/videos y escribe data/visuales.json: la ficha de cada clip de +la galeria (de que peli sale, quien aparece, cuanto dura, si es una silueta +de verdad y como de llena esta) mas una miniatura en data/miniaturas. + +El panel lo usa para listar los clips por pelicula y con nombre legible en +vez del nombre del archivo, y para avisar de los que casi no tienen imagen. + + scripts/catalogar-visuales.py # cataloga y escribe el JSON + scripts/catalogar-visuales.py --limpiar # ademas aparta los que no son + # siluetas (se ve el fondo) + +Como distingue una silueta de un trozo de pelicula tal cual: en un clip +siluetado el fondo es negro PURO (valor 0), asi que basta medir que +porcentaje del fotograma esta a cero. En los clips siluetados de la galeria +sale 0,63-0,90; en los recortes sin siluetear, 0,04-0,06. No hay zona gris. + +Necesita ffmpeg/ffprobe y numpy. +""" +import argparse +import json +import re +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +try: + import numpy as np +except ImportError: + sys.exit("Falta numpy: pip install numpy (o usa el .venv de DETEKTION)") + +BASE = Path(__file__).resolve().parent.parent +VIDEOS = BASE / "data" / "videos" +MINIS = BASE / "data" / "miniaturas" +SALIDA = BASE / "data" / "visuales.json" +DESCARTES = VIDEOS / "_descartados" + +VIDEO_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv") +IMAGE_EXT = (".jpg", ".jpeg", ".png", ".gif", ".webp") + +# Muestreo para las metricas: 2 fotogramas por segundo a 160x90 en gris. +FPS_MUESTRA = 2 +MW, MH = 160, 90 + +# Umbrales (ver cabecera: medidos sobre la galeria real, no inventados) +UMBRAL_SILUETA = 0.30 # % de negro puro a partir del cual es una silueta +UMBRAL_FIGURA = 32 # gris a partir del cual cuenta como figura +VACIA_FIGURA = 0.005 # menos del 0,5% del cuadro = fotograma sin nada +CALIDAD_VACIA = 0.005 # mediana de figura por debajo => clip sin contenido +CALIDAD_FLOJA = 0.03 # mediana por debajo => clip flojo (poca imagen) +MUERTOS_VACIA = 0.50 # mas de la mitad de fotogramas vacios => sin contenido + +# -------------------------------------------------------------------------- +# Nombres: de que obra sale cada clip y quien aparece +# -------------------------------------------------------------------------- +OBRAS = { + "p1": ("Piratas del Caribe: La maldicion de la Perla Negra", 2003), + "p2": ("Piratas del Caribe: El cofre del hombre muerto", 2006), + "p3": ("Piratas del Caribe: En el fin del mundo", 2007), + "p4": ("Piratas del Caribe: En mareas misteriosas", 2011), + "p5": ("Piratas del Caribe: La venganza de Salazar", 2017), + "hackers": ("Hackers", 1995), + "tron": ("Tron", 1982), + "devs": ("Devs", 2020), + "avatar": ("Avatar", 2009), + # Claves de varias palabras: se prueban de la mas larga a la mas corta + "rick-and-morty": ("Rick and Morty", 2013), + "rickmorty": ("Rick and Morty", 2013), + "los-pinguinos-de-madagascar": ("Los pinguinos de Madagascar", 2014), + "pinguinos": ("Los pinguinos de Madagascar", 2014), + "el-club-de-la-lucha": ("El club de la lucha", 1999), + "fightclub": ("El club de la lucha", 1999), + "alien-earth": ("Alien Earth", 2025), +} + +# Personajes reconocidos. Las claves con guion son de dos palabras y se +# prueban antes que las sueltas. +PERSONAJES = { + "bootstrap-bill": "Bootstrap Bill Turner", + "davy-jones": "Davy Jones", + "jack": "Jack Sparrow", + "will": "Will Turner", + "elizabeth": "Elizabeth Swann", + "barbossa": "Hector Barbossa", + "barbanegra": "Barbanegra", + "angelica": "Angelica", + "carina": "Carina Smyth", + "henry": "Henry Turner", + "salazar": "Capitan Salazar", + "gibbs": "Joshamee Gibbs", + "pintel": "Pintel", + "ragetti": "Ragetti", + "norrington": "James Norrington", + "beckett": "Cutler Beckett", + "calypso": "Tia Dalma", +} + +# Lo que no es un personaje describe la escena. Igual que PERSONAJES, las +# claves con guion son de dos palabras y se prueban antes que las sueltas. +ESCENAS = { + "consejo-piratas": "consejo de piratas", + "nina": "de nina", + "tripulacion": "y su tripulacion", + "pirata": "pirata", + "cielo": "contra el cielo", + "tormenta": "en la tormenta", + "consejo": "consejo", + "piratas": "de piratas", + "duelo": "duelo", + "holandes": "el Holandes Errante", + "singapur": "Singapur", + "sirenas": "sirenas", +} + +MODOS = {"negro": "silueta sobre negro", "croma": "croma verde", + "sombra": "sombra solida", + "alfa": "transparencia real (canal alfa)"} + +# Morralla de los nombres de descarga: no describe nada del clip. +RUIDO = {"remastered", "bluray", "brrip", "webrip", "web", "webdl", "hdtv", + "extended", "proper", "repack", "x264", "x265", "hevc", "h264", + "aac", "ac3", "dts", "10bit", "1080p", "720p", "2160p", "4k", + "yify", "yts", "eztv", "rarbg", "sample"} + + +def bonito(txt): + return txt.replace("-", " ").replace("_", " ").strip().capitalize() + + +def analizar_nombre(stem): + """Del nombre de archivo saca obra, personajes, etiquetas y titulo.""" + # Las dos convenciones de DETEKTION: + # siluetear.py -> _silueta-.mp4 (ya recortado) + # recortar.py -> _13m26s.mp4 (corte crudo, sin siluetear) + tipo, modo, momento, origen = "clip", "", "", "manual" + resto = stem + if "_silueta-" in stem: + resto, modo = stem.split("_silueta-", 1) + tipo, origen = "silueta", "detektion" + elif "_" in stem: + resto, cola = stem.split("_", 1) + if re.fullmatch(r"\d+m\d+s", cola): + momento, tipo, origen = cola, "corte", "detektion" + + trozos = resto.split("-") + # Se prueba el prefijo mas largo primero: "rick-and-morty" antes que + # "rick", que si no cada serie de varias palabras se parte mal. + obra = anio = None + for n in range(min(5, len(trozos)), 0, -1): + clave = "-".join(trozos[:n]).lower() + if clave in OBRAS: + obra, anio = OBRAS[clave] + trozos = trozos[n:] + break + if not obra: + # Nombre largo tipo "hackers-1995-remastered-1080p-bluray": busca la + # obra por cualquiera de sus trozos antes de rendirse. + for i, t in enumerate(trozos): + if t.lower() in OBRAS: + obra, anio = OBRAS[t.lower()] + trozos = trozos[i + 1:] + break + if not obra: + obra, anio = bonito(resto), None + + personajes, etiquetas = [], [] + i = 0 + while i < len(trozos): + par = "-".join(trozos[i:i + 2]).lower() + uno = trozos[i].lower() + if par in PERSONAJES: + personajes.append(PERSONAJES[par]); i += 2; continue + if par in ESCENAS: + etiquetas.append(ESCENAS[par]); i += 2; continue + if uno in PERSONAJES: + personajes.append(PERSONAJES[uno]) + elif uno in ESCENAS: + etiquetas.append(ESCENAS[uno]) + elif re.fullmatch(r"s\d{1,2}e\d{1,2}", uno): + etiquetas.append(uno.upper()) # capitulo: S09E01 + elif re.fullmatch(r"\d+m\d+s", uno): + etiquetas.append("min " + uno.replace("m", ":").rstrip("s")) + elif uno and uno not in RUIDO and not uno.isdigit(): + etiquetas.append(uno) + i += 1 + + if personajes: + titulo = " y ".join(personajes) + if etiquetas: + titulo += " (" + ", ".join(etiquetas) + ")" + elif etiquetas: + titulo = ", ".join(etiquetas).capitalize() + elif momento: + titulo = "Fragmento en " + momento.replace("m", " min ").replace("s", " s") + else: + titulo = bonito(resto) + + return {"obra": obra, "anio": anio, "titulo": titulo, "tipo": tipo, + "modo": MODOS.get(modo, modo), "momento": momento, + "origen": origen, "personajes": personajes, "etiquetas": etiquetas} + + +# -------------------------------------------------------------------------- +# Medidas +# -------------------------------------------------------------------------- +def ffprobe(path): + cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height,r_frame_rate", + "-show_entries", "format=duration", "-of", "json", str(path)] + try: + out = json.loads(subprocess.run(cmd, capture_output=True, + text=True, check=True).stdout) + except (subprocess.CalledProcessError, json.JSONDecodeError): + return None + st = (out.get("streams") or [{}])[0] + fps = 0.0 + try: + num, den = st.get("r_frame_rate", "0/1").split("/") + fps = round(float(num) / float(den), 2) if float(den) else 0.0 + except (ValueError, ZeroDivisionError): + pass + return {"ancho": st.get("width", 0), "alto": st.get("height", 0), + "fps": fps, + "duracion": round(float(out.get("format", {}).get("duration") or 0), 2)} + + +def muestrear(path, canal_alfa=False): + """Fotogramas muestreados en gris, o None si no decodifica. + + Con canal_alfa mide la TRANSPARENCIA en vez de la luminancia: hace falta + para los .webm de silueta, donde lo que separa figura de fondo es el + canal alfa y no el negro. Ojo con el decodificador: el vp9 nativo de + ffmpeg se come el alfa en silencio (devuelve todo opaco), asi que hay + que pedir libvpx-vp9 a mano. + """ + cmd = ["ffmpeg", "-v", "error"] + if canal_alfa: + cmd += ["-c:v", "libvpx-vp9"] + cmd += ["-i", str(path), "-vf", + f"fps={FPS_MUESTRA},scale={MW}:{MH}," + # alphaextract necesita que le llegue un formato CON alfa: sin el + # format=rgba delante, el filtro no negocia y ffmpeg aborta + + ("format=rgba,alphaextract" if canal_alfa else "format=gray"), + "-pix_fmt", "gray", "-f", "rawvideo", "-"] + raw = subprocess.run(cmd, capture_output=True).stdout + n = len(raw) // (MW * MH) + if not n: + return None + return np.frombuffer(raw[:n * MW * MH], dtype=np.uint8).reshape(n, MH, MW) + + +def medir(fr): + negro = float((fr <= 2).mean()) + figura = (fr > UMBRAL_FIGURA).mean(axis=(1, 2)) + return { + "negroPuro": round(negro, 3), + "figura": round(float(np.median(figura)), 4), + "figuraPico": round(float(np.percentile(figura, 90)), 4), + "brillo": round(float(fr.mean()), 2), + "vacios": round(float((figura < VACIA_FIGURA).mean()), 2), + "_mejor": int(np.argmax(figura)), + } + + +def clasificar(m, es_alfa=False): + """(clase, calidad, aviso). clase: silueta | con-fondo. + + Con es_alfa las metricas vienen del canal alfa, no de la luminancia: + 'negroPuro' es entonces el porcentaje de cuadro transparente. Un clip + con alfa nunca puede ser 'con-fondo' — como mucho, opaco entero. + """ + if m["negroPuro"] < UMBRAL_SILUETA: + if es_alfa: + return ("silueta", "vacia", + "Lleva canal alfa pero casi no hay zona transparente: " + "el recorte no separo la figura del fondo.") + return ("con-fondo", "descartable", + "No esta siluetado: se ve el fondo entero de la pelicula. " + "Pasalo por DETEKTION (siluetear.py) antes de usarlo.") + if m["figura"] < CALIDAD_VACIA or m["vacios"] > MUERTOS_VACIA: + return ("silueta", "vacia", + "Silueta correcta pero casi siempre vacia: apenas hay figura.") + if m["figura"] < CALIDAD_FLOJA: + return ("silueta", "floja", + "Figura pequena o muy oscura: se vera poco sobre el fondo.") + return ("silueta", "buena", "") + + +def miniatura(path, segundo, destino): + destino.parent.mkdir(parents=True, exist_ok=True) + cmd = ["ffmpeg", "-v", "error", "-ss", f"{segundo:.2f}", "-i", str(path), + "-frames:v", "1", "-vf", "scale=320:-2", "-q:v", "4", + str(destino), "-y"] + return subprocess.run(cmd, capture_output=True).returncode == 0 + + +# -------------------------------------------------------------------------- +def catalogar(limpiar=False): + if not VIDEOS.is_dir(): + sys.exit(f"No existe {VIDEOS}") + fichas, apartados = [], [] + + archivos = sorted(f for f in VIDEOS.iterdir() + if f.is_file() + and f.suffix.lower() in VIDEO_EXT + IMAGE_EXT) + for f in archivos: + ficha = {"archivo": f.name, "peso": f.stat().st_size} + ficha.update(analizar_nombre(f.stem)) + + if f.suffix.lower() in IMAGE_EXT: + ficha.update({"clase": "imagen", "calidad": "buena", "aviso": "", + "tipo": "imagen"}) + info = ffprobe(f) or {} + ficha.update(info) + mini = MINIS / (f.stem + ".jpg") + if miniatura(f, 0, mini): + ficha["miniatura"] = "miniaturas/" + mini.name + fichas.append(ficha) + print(f" imagen {f.name}") + continue + + info = ffprobe(f) + if not info: + print(f" ILEGIBLE {f.name}") + ficha.update({"clase": "roto", "calidad": "descartable", + "aviso": "ffprobe no puede leerlo."}) + fichas.append(ficha) + continue + ficha.update(info) + + # Las siluetas con canal alfa se miden por la transparencia + es_alfa = f.name.endswith("_silueta-alfa.webm") + fr = muestrear(f, canal_alfa=es_alfa) + if fr is None: + ficha.update({"clase": "roto", "calidad": "descartable", + "aviso": "No se pudo decodificar ningun fotograma."}) + fichas.append(ficha) + print(f" ILEGIBLE {f.name}") + continue + + m = medir(fr) + mejor = m.pop("_mejor") / FPS_MUESTRA + clase, calidad, aviso = clasificar(m, es_alfa) + ficha.update({"metricas": m, "clase": clase, "calidad": calidad, + "aviso": aviso}) + + mini = MINIS / (f.stem + ".jpg") + if miniatura(f, mejor, mini): + ficha["miniatura"] = "miniaturas/" + mini.name + + if limpiar and clase == "con-fondo": + DESCARTES.mkdir(parents=True, exist_ok=True) + shutil.move(str(f), str(DESCARTES / f.name)) + if mini.exists(): + mini.unlink() + apartados.append(f.name) + print(f" APARTADO {f.name} ({aviso})") + continue + + fichas.append(ficha) + print(f" {calidad:<10} {f.name[:44]:<44} " + f"negro={m['negroPuro']:.2f} figura={m['figura']:.3f}") + + datos = { + "generado": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "total": len(fichas), + "clips": fichas, + } + SALIDA.parent.mkdir(parents=True, exist_ok=True) + SALIDA.write_text(json.dumps(datos, indent=2, ensure_ascii=False), + encoding="utf-8") + print(f"\n{len(fichas)} fichas -> {SALIDA.relative_to(BASE)}") + if apartados: + print(f"{len(apartados)} apartados -> {DESCARTES.relative_to(BASE)}/") + porcal = {} + for x in fichas: + porcal[x.get("calidad", "?")] = porcal.get(x.get("calidad", "?"), 0) + 1 + print("Calidad: " + ", ".join(f"{k}={v}" for k, v in sorted(porcal.items()))) + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description="Cataloga la galeria de FOSFENO") + ap.add_argument("--limpiar", action="store_true", + help="aparta en _descartados/ los clips sin siluetear") + catalogar(ap.parse_args().limpiar) diff --git a/web/package.json b/web/package.json index 91a07d5..a633e19 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,7 @@ "butterchurn-presets": "^2.4.7", "codemirror": "^5.65.16", "hydra-synth": "^1.3.29", + "milkdrop-preset-converter": "^0.1.2", "qrcode-generator": "^1.4.4", "socket.io-client": "^4.7.5" } diff --git a/web/panel/index.html b/web/panel/index.html index 76ce776..114bb1d 100644 --- a/web/panel/index.html +++ b/web/panel/index.html @@ -9,15 +9,33 @@ -
-

FOSFENO

- -
+ +
+
+

FOSFENO

+ + + +
- - + - -
+ + + +
Mapping
- -
- + + +
+ Previsualizacion (arrastra los puntos) + +

Arrastra los puntos para encajar cada capa en su sitio. + Toca dentro de una capa para seleccionarla. Se guarda solo.

+
+ + +
+
+ Capas + +
+
+
- Previsualizacion (arrastra los puntos) - - +

Capa = una zona que PROYECTA algo (eliges que en + Propiedades). Mascara = un recuadro negro que TAPA lo que haya + debajo; no lleva visual.

-

Arrastra los puntos para deformar. Selecciona una - superficie y usa + malla para subdividir y curvarla. - + Mascara tapa zonas con un recuadro negro (recorta derrames - de luz). Se guarda solo.

+

Sin capas: con el mapping activado + y ninguna capa, sale el motor a pantalla completa. Pulsa + + Capa para trocear la proyeccion en zonas.

+
+ + +
+
+ Propiedades + +
+ +

Selecciona una capa (en la lista o + tocando dentro de ella en la previsualizacion) para elegir que + visual se proyecta en esa zona.

+ + + +
diff --git a/web/panel/panel.css b/web/panel/panel.css index 0f0efa9..9bce58c 100644 --- a/web/panel/panel.css +++ b/web/panel/panel.css @@ -35,11 +35,17 @@ body { padding-bottom: 48px; } -/* --- Cabecera con el titulo centrado --- */ +/* --- Barra superior fija: titulo + pestanas + avisos --- */ +.topbar { + position: sticky; top: 0; z-index: 10; background: var(--bg); +} + +/* --- Cabecera con el titulo centrado y las pestanas a su derecha --- */ header { - position: sticky; top: 0; z-index: 10; + position: relative; display: flex; align-items: center; justify-content: center; - padding: 16px 20px; border-bottom: 2px solid var(--green-d); + flex-wrap: wrap; gap: 10px 20px; + padding: 14px 46px; border-bottom: 2px solid var(--green-d); background: var(--bg); } header h1 { @@ -50,6 +56,35 @@ header h1 { #conn { position: absolute; right: 20px; top: 50%; transform: translateY(-50%); } + +/* --- Pestanas MOTORES / MAPPING --- */ +.tabs { display: flex; gap: 8px; } +.tab { + position: relative; cursor: pointer; + font-family: "Xirod", "Arial Black", sans-serif; + font-size: 12px; font-weight: 400; letter-spacing: 0.07em; + padding: 10px 15px; border-radius: 10px; + background: #161d10; border: 2px solid var(--line); color: var(--dim); +} +.tab.active { + border-color: var(--orange); color: var(--orange-n); + box-shadow: 0 0 12px rgba(255, 122, 0, 0.35); +} +/* Punto verde en la pestana MAPPING cuando el mapping esta activado, para + saber desde MOTORES que la salida se esta deformando. */ +.tabdot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: var(--green); box-shadow: 0 0 8px var(--green); + margin-left: 7px; vertical-align: middle; +} + +/* En movil el titulo baja de tamano para que las pestanas quepan al lado */ +@media (max-width: 560px) { + header { padding: 12px 34px; gap: 8px 12px; } + header h1 { font-size: 23px; } + .tab { padding: 9px 11px; font-size: 11px; } + #conn { right: 12px; } +} .dot { width: 13px; height: 13px; border-radius: 50%; display: inline-block; } .dot.on { background: var(--green); box-shadow: 0 0 9px var(--green); } .dot.off { background: var(--red); box-shadow: 0 0 9px var(--red); } @@ -82,6 +117,61 @@ main { } } +/* --- Vistas (pestanas) --- + MOTORES ensena todo menos las tarjetas de mapping, y al reves. La vista + "capa" es MAPPING recortado: solo la previa y las propiedades de UNA capa, + sin la lista, para poder centrarse en su efecto. */ +body[data-view="motores"] [data-vista="mapping"] { display: none !important; } +body[data-view="mapping"] main > .column > :not([data-vista="mapping"]), +body[data-view="capa"] main > .column > :not([data-vista="mapping"]) { + display: none !important; +} +body[data-view="capa"] #card-capas { display: none !important; } +body[data-view="mapping"] main, +body[data-view="capa"] main { + flex-direction: column; width: 100%; max-width: 900px; +} +body[data-view="mapping"] .column, +body[data-view="capa"] .column { display: contents; } + +/* Pestanas de capa: mas pequenas que MOTORES/MAPPING, para que se lean como + lo que son (un atajo a cada capa) y no compitan con las dos principales. */ +.tab.tabcapa, .tab.tabnew { + font-size: 0.78em; padding: 6px 10px; letter-spacing: 0.06em; + max-width: 11ch; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} +.tab.tabnew { border-style: dashed; opacity: 0.85; } +.tab.tabnew:hover { opacity: 1; } + +/* En pantalla ancha el mapping se reparte: la previsualizacion grande a la + izquierda (que es donde se trabaja) y las capas con sus propiedades a la + derecha, sin tener que bajar y subir todo el rato. */ +@media (min-width: 1000px) { + body[data-view="mapping"] main { + display: grid; width: 96%; max-width: 1700px; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + grid-template-areas: "previa capas" "previa props" "previa ."; + align-items: start; align-content: start; gap: 16px; + } + body[data-view="mapping"] #card-mapping { grid-area: previa; } + body[data-view="mapping"] #card-capas { grid-area: capas; } + body[data-view="mapping"] #card-propiedades { grid-area: props; } + /* La previsualizacion manda: que ocupe todo el alto util disponible */ + body[data-view="mapping"] #map-preview { aspect-ratio: 16 / 9; } + + /* La vista de una capa: previa a la izquierda, sus ajustes a la derecha */ + body[data-view="capa"] main { + display: grid; width: 96%; max-width: 1700px; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + grid-template-areas: "previa props"; + align-items: start; align-content: start; gap: 16px; + } + body[data-view="capa"] #card-mapping { grid-area: previa; } + body[data-view="capa"] #card-propiedades { grid-area: props; } + body[data-view="capa"] #map-preview { aspect-ratio: 16 / 9; } +} + /* --- Tarjetas --- */ .card { background: var(--card); border: 1px solid var(--line); @@ -90,11 +180,12 @@ main { } .card.center { align-items: center; } -/* Cabecera de tarjeta: titulo centrado, boton de info a la derecha */ +/* Cabecera de tarjeta: titulo centrado, boton de info a la derecha. + El hueco lateral evita que un titulo largo se meta debajo del boton. */ .cardhead { position: relative; width: 100%; display: flex; align-items: center; justify-content: center; - min-height: 30px; + min-height: 30px; padding: 0 34px; } /* Titulo de cada tarjeta: mas grande y en la fuente Xirod */ .cardhead .label { @@ -108,8 +199,12 @@ main { .hint { color: var(--dim); font-size: 12px; line-height: 1.45; } .row { display: flex; align-items: center; justify-content: space-between; - gap: 10px; } + gap: 10px; flex-wrap: wrap; } .row .cmd { flex: 1; } +/* En una fila de etiqueta + desplegable, el desplegable no baja de un ancho + usable: si no cabe, la fila parte en dos lineas en vez de estrujarlo. */ +.row .label { flex: 1 1 auto; } +.row select.inline { flex: 0 1 auto; min-width: 148px; max-width: 100%; } /* --- Boton de informacion (circular naranja, esquina de la tarjeta) --- */ .info { @@ -139,11 +234,18 @@ main { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; } .engine { - width: 96px; height: 96px; border-radius: 50%; + width: 96px; aspect-ratio: 1 / 1; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; cursor: pointer; background: var(--card); border: 3px solid var(--line); color: var(--txt); } +/* En movil, algo mas pequenos para que entren TRES por fila: con 96 px se + quedaban en un 2+2+1 descuadrado (flex parte la linea antes de encoger). */ +@media (max-width: 560px) { + .engines { gap: 10px; } + .engine { width: 86px; } + .engine strong { font-size: 12px; } +} .engine strong { font-size: 13px; } .engine small { font-size: 9px; color: var(--dim); } .engine.active { @@ -222,15 +324,14 @@ input[type=range] { width: 100%; accent-color: var(--green); height: 30px; } font-size: 12px; font-family: monospace; padding: 4px 0 8px; } -/* --- Banda de avisos --- */ +/* --- Banda de avisos (dentro de la barra superior fija) --- */ .notif { display: flex; align-items: center; gap: 10px; padding: 12px 16px; font-size: 14px; font-weight: 600; - position: sticky; top: 66px; z-index: 9; } -.notif.info { background: var(--green); color: var(--ink); } -.notif.warn { background: var(--orange); color: var(--ink); } -.notif.error { background: var(--red); color: #fff; } +.notif.lv-info { background: var(--green); color: var(--ink); } +.notif.lv-warn { background: var(--orange); color: var(--ink); } +.notif.lv-error { background: var(--red); color: #fff; } .notif span { flex: 1; } .notif button { background: transparent; border: none; color: inherit; @@ -257,6 +358,70 @@ input[type=range] { width: 100%; accent-color: var(--green); height: 30px; } } .modal-box p { color: var(--txt); font-size: 14px; line-height: 1.5; } +/* --- Bloques agrupados dentro de una tarjeta --- + Un grupo de controles que aparece o desaparece junto (por ejemplo los de + la camara o los del fondo Butter en el mezclador). */ +.stack { display: flex; flex-direction: column; gap: 12px; } + +/* Controles que ahora mismo no pintan nada: siguen visibles pero apagados, + asi se ve que existen sin que despisten. */ +.dim { opacity: 0.42; } + +/* Resumen del mezclador: la linea que dice que va a salir por el proyector */ +.hint.resumen { + border-left: 3px solid var(--orange); padding: 6px 0 6px 10px; + color: var(--txt); font-size: 13px; +} + +/* --- Previsualizacion del mapping --- */ +#map-preview { + width: 100%; aspect-ratio: 16 / 9; display: block; margin: 6px 0; + background: #0a0a0a; border: 1px solid var(--line); border-radius: 8px; + touch-action: none; cursor: crosshair; +} + +/* --- Lista de capas del mapping --- + Una fila por capa: nombre + fuente a la izquierda, botones a la derecha. + La fila entera selecciona; los botones no la seleccionan. */ +#map-list { display: flex; flex-direction: column; gap: 8px; } +.capa { + display: flex; align-items: center; gap: 8px; cursor: pointer; + padding: 9px 10px; border-radius: 11px; + background: #161d10; border: 2px solid var(--line); +} +.capa.sel { border-color: var(--orange); box-shadow: 0 0 12px rgba(255,122,0,0.3); } +.capa.oculta { opacity: 0.45; } +.capa .txt { flex: 1; min-width: 0; } +.capa .nom { + display: block; color: var(--txt); font-weight: 700; font-size: 14px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.capa .src { + display: block; color: var(--dim); font-size: 11px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.capa.mascara .nom { color: #ff6b6b; } +.capa button { + flex: 0 0 auto; width: 34px; height: 34px; border-radius: 9px; cursor: pointer; + background: #0d120c; border: 1px solid var(--line); color: var(--txt); + font-size: 14px; font-weight: 700; line-height: 1; padding: 0; +} +.capa button:disabled { opacity: 0.3; } +.capa .del { color: var(--red); } + +/* Malla: los +/- son botones pequenos y van juntos, no barras a lo ancho */ +.grupo { display: inline-flex; align-items: center; gap: 8px; flex: 0 0 auto; } +#map-sub-dec, #map-sub-inc { + flex: 0 0 auto; width: 46px; padding: 10px 0; font-size: 17px; +} +#map-sub-val { min-width: 40px; text-align: center; } + +/* Campo de texto (nombre de la capa) */ +.txt[type="text"] { + flex: 1; padding: 10px; border-radius: 10px; font-size: 14px; + background: #161d10; color: var(--txt); border: 1px solid var(--line); +} + /* Favoritos de projectM: lista de visuales para pinchar en directo */ #pm-fav-list { display: flex; flex-direction: column; gap: 8px; diff --git a/web/panel/panel.js b/web/panel/panel.js index 58497af..3a31dce 100644 --- a/web/panel/panel.js +++ b/web/panel/panel.js @@ -4,7 +4,10 @@ * Incluye editor de codigo, mezclador VJ, ventana de ayuda y avisos. */ -const socket = io(); +// Clave compartida (config.json -> auth.token). Llega en la direccion del +// panel, que es la que trae el codigo QR del proyector. Vacia = sin clave. +const CLAVE = new URLSearchParams(location.search).get("k") || ""; +const socket = io({ auth: { token: CLAVE } }); let state = null; let editorEngine = ""; // que motor tiene cargado el editor let helpData = {}; // textos de ayuda @@ -13,6 +16,91 @@ let lastNotifT = 0; // marca de tiempo del ultimo aviso mostrado const $ = (s) => document.querySelector(s); const $$ = (s) => document.querySelectorAll(s); +// ========================================================================== +// Vistas: MOTORES (lo de siempre) y MAPPING (encaje sobre la superficie) +// Se separan para no mezclar los controles de directo con los de montaje. +// La eleccion se recuerda en el propio navegador. +// ========================================================================== +/* Ademas de MOTORES y MAPPING hay una vista por CAPA ("capa:"): ensena + solo la previsualizacion y las propiedades de esa capa, para poder trastear + con su efecto sin el resto del panel delante. Es lo que hace usable tener + varias capas: sin esto habia que ir a MAPPING, buscarla en la lista y + volver. */ +/* mapSurfaces se declara mas abajo con let: al restaurar la vista guardada + esta funcion corre ANTES que esa linea, y tocarla daria ReferenceError. */ +function capasHoy() { + try { return mapSurfaces || []; } catch (e) { return []; } +} + +function setView(view) { + const esCapa = String(view).startsWith("capa:"); + if (!esCapa && view !== "mapping") view = "motores"; + if (esCapa) { + const id = view.slice(5); + // Si la capa ya no existe (borrada desde otro movil), no dejamos el panel + // en una vista fantasma + if (!capasHoy().some((s) => s.id === id)) return setView("mapping"); + mapSel = { type: "surface", id: id }; + document.body.dataset.capa = id; + } else { + delete document.body.dataset.capa; + } + document.body.dataset.view = esCapa ? "capa" : view; + vistaActual = view; + $$(".tab").forEach((t) => + t.classList.toggle("active", t.dataset.view === view)); + try { localStorage.setItem("fosfeno.view", view); } catch (e) { /* privado */ } + // El lienzo del mapping mide 0 mientras esta oculto: hay que repintarlo + // al entrar en su vista, ya con su tamano real. + if (state && (view === "mapping" || esCapa)) { + renderPropiedades(); + drawMapPreview(); + } +} +let vistaActual = "motores"; + +/* Rehace las pestanas de capa. Solo toca el DOM si algo cambio: si no, cada + estado que llega (y llegan muchos) tiraria la pestana que el usuario esta + pulsando. */ +function renderTabsCapas() { + const cont = $("#tabs-capas"); + if (!cont) return; + const capas = capasHoy(); + const sig = capas.map((s, i) => s.id + ":" + (s.nombre || i + 1)).join("|"); + if (cont.dataset.sig !== sig) { + cont.dataset.sig = sig; + cont.innerHTML = ""; + capas.forEach((s, i) => { + const b = document.createElement("button"); + b.className = "tab tabcapa"; + b.dataset.view = "capa:" + s.id; + b.textContent = (s.nombre || "CAPA " + (i + 1)).toUpperCase(); + b.title = "Editar esta capa"; + b.addEventListener("click", () => setView("capa:" + s.id)); + cont.appendChild(b); + }); + } + // La capa activa pudo desaparecer: volver a MAPPING antes de quedarnos en + // una vista sin contenido + if (vistaActual.startsWith("capa:") + && !capas.some((s) => s.id === vistaActual.slice(5))) { + setView("mapping"); + return; + } + $$(".tab").forEach((t) => + t.classList.toggle("active", t.dataset.view === vistaActual)); +} + +$$(".tab[data-view]").forEach((t) => + t.addEventListener("click", () => setView(t.dataset.view))); + +(function () { + let saved = "motores"; + try { saved = localStorage.getItem("fosfeno.view") || "motores"; } + catch (e) { /* almacenamiento no disponible */ } + setView(saved); +})(); + // Sliders del mezclador VJ (se generan dinamicamente) const MIXER_SLIDERS = [ { key: "mix", label: "Mezcla A/B", min: 0, max: 1, step: 0.01 }, @@ -109,7 +197,9 @@ let notifTimer = null; function showNotif(entry) { if (!entry || !entry.message) return; const n = $("#notif"); - n.className = "notif " + (entry.level || "info"); + // El nivel va con prefijo 'lv-': sin el, un aviso 'info' cogia tambien el + // estilo del boton redondo de ayuda (.info) y salia como una pelotita. + n.className = "notif lv-" + (entry.level || "info"); $("#notif-msg").textContent = entry.message; n.hidden = false; clearTimeout(notifTimer); @@ -132,7 +222,7 @@ function buildMixerSliders() { const wrap = document.createElement("div"); wrap.className = "slider"; wrap.innerHTML = - `
${cfg.label}` + + `
${cfg.label}` + `-
`; const input = document.createElement("input"); input.type = "range"; @@ -187,12 +277,17 @@ function render() { renderProjectM(); renderMapping(); + // Aviso en la pestana: el mapping esta deformando la salida aunque + // estemos mirando la pestana de motores. + $("#tab-map-dot").hidden = !(state.mapping && state.mapping.enabled); + $("#now").textContent = nowText() || "-"; const net = state.network || {}; const port = (net.port && net.port !== 80) ? ":" + net.port : ""; + const k = CLAVE ? "?k=" + encodeURIComponent(CLAVE) : ""; $("#net-foot").textContent = "Panel: http://" + - (net.hostname || "fosfeno") + ".local" + port + "/" + + (net.hostname || "fosfeno") + ".local" + port + "/" + k + (net.ip ? " (" + net.ip + ")" : ""); // Aviso pendiente recibido antes de abrir el panel @@ -218,16 +313,26 @@ function nowText() { return state.status.label || ""; } +// Opciones fijas que sobreviven al rellenar cada lista: el "-- elige ... --" +// de arriba, y lo que se ensena cuando no hay nada que listar. +const SELECT_FIJA = { + "mix-video": '', + "prop-video": '', + "prop-shader": '', +}; +const SELECT_VACIA = { + "mix-camera": '', + "prop-camara": '', + "prop-video": '', + "prop-preset": '', +}; + function fillSelectOnce(sel, items, valFn, txtFn) { items = items || []; if (sel.dataset.count == items.length) return; sel.dataset.count = items.length; - let keep = ""; - if (sel.id === "mix-video") { - keep = ''; - } else if (sel.id === "mix-camera" && items.length === 0) { - keep = ''; - } + let keep = SELECT_FIJA[sel.id] || ""; + if (!items.length && SELECT_VACIA[sel.id]) keep = SELECT_VACIA[sel.id]; sel.innerHTML = keep; items.forEach((it) => { const opt = document.createElement("option"); @@ -237,12 +342,80 @@ function fillSelectOnce(sel, items, valFn, txtFn) { }); } +/* Lista de clips con la ficha del catalogo (data/visuales.json, que genera + scripts/catalogar-visuales.py): agrupada por pelicula y con el nombre del + personaje en vez del nombre del archivo. Los clips que casi no tienen + figura salen marcados, que es lo que evita elegir a ciegas uno que sobre el + proyector se ve negro. Sin catalogo cae en la lista de siempre. */ +const MARCA_CALIDAD = { floja: " · poca figura", vacia: " · casi vacio" }; + +function fichaClip(archivo) { + return ((state.meta && state.meta.visuales) || {})[archivo] || null; +} + +/* Nombre corto para las frases ("Jack Sparrow y Will Turner") */ +function tituloClip(archivo) { + const f = fichaClip(archivo); + return (f && f.titulo) || archivo; +} + +/* Nombre largo para las listas: titulo + duracion + aviso de calidad */ +function etiquetaClip(archivo) { + const f = fichaClip(archivo); + if (!f) return archivo; + const dur = f.duracion ? " " + Math.round(f.duracion) + "s" : ""; + return f.titulo + dur + (MARCA_CALIDAD[f.calidad] || ""); +} + +function fillClipSelect(sel) { + const vs = (state.meta && state.meta.videos) || []; + const cat = (state.meta && state.meta.visuales) || {}; + // La firma incluye el catalogo: si se recataloga sin cambiar el numero de + // archivos, la lista tambien tiene que rehacerse. + const sig = vs.length + "|" + Object.keys(cat).length; + if (sel.dataset.sig === sig) return; + sel.dataset.sig = sig; + delete sel.dataset.count; // no dejar que fillSelectOnce lo de por hecho + + sel.innerHTML = (vs.length ? SELECT_FIJA[sel.id] : SELECT_VACIA[sel.id]) + || SELECT_FIJA[sel.id] || ""; + + // Agrupadas por obra, en el orden en que vienen los archivos (p1, p2...); + // lo que no tiene ficha va al final, en "Sin catalogar". + const grupos = new Map(); + vs.forEach((v) => { + const f = cat[v]; + const obra = (f && f.obra) || "Sin catalogar"; + if (!grupos.has(obra)) grupos.set(obra, []); + grupos.get(obra).push(v); + }); + + grupos.forEach((lista, obra) => { + const destino = grupos.size > 1 + ? sel.appendChild(Object.assign(document.createElement("optgroup"), + { label: obra })) + : sel; + lista.forEach((v) => { + const opt = document.createElement("option"); + opt.value = v; + opt.textContent = etiquetaClip(v); + const f = cat[v]; + if (f && f.aviso) opt.title = f.aviso; + destino.appendChild(opt); + }); + }); +} + function renderButterchurn() { if (state.engine !== "butterchurn") return; const b = state.butterchurn; fillSelectOnce($("#bc-preset"), state.meta.butterchurnPresets, (n) => n, (n) => n); - if (b.preset) $("#bc-preset").value = b.preset; + // El preset se sigue solo con el cambio automatico: no lo movemos mientras + // el usuario tiene la lista abierta eligiendo. + if (b.preset && document.activeElement !== $("#bc-preset")) { + $("#bc-preset").value = b.preset; + } $("#bc-shuffle").checked = b.shuffle; $("#bc-mode-seconds").classList.toggle("active", b.intervalMode === "seconds"); $("#bc-mode-beats").classList.toggle("active", b.intervalMode === "beats"); @@ -272,22 +445,76 @@ function renderEditor() { } } +/* Frase de una linea que dice exactamente que va a salir por el proyector + con los ajustes actuales. Es la respuesta a "y esto ahora que hace": el + mezclador tiene dos capas (fondo + clip) y tres formas de combinarlas. */ +function mixerResumen(m) { + const fondo = m.fondo === "butter" + ? "las visuales Butter (MilkDrop)" : "la camara"; + const clip = m.video ? tituloClip(m.video) : "el clip"; + if (m.source === "video") { + return m.video + ? "En pantalla: solo " + clip + ", a pantalla completa. El fondo no se usa." + : "En pantalla: nada todavia, elige un clip en la lista."; + } + if (m.source === "cam") { + return "En pantalla: solo " + fondo + ", a pantalla completa. " + + "El clip no se usa."; + } + if (m.blendMode === "recorte") { + return "En pantalla: " + clip + " recortado (su negro se vuelve " + + "transparente) DELANTE de " + fondo + ". El deslizador de arriba " + + "ajusta el umbral del recorte."; + } + const modo = $("#mix-blend").selectedOptions[0]; + return "En pantalla: " + clip + " y " + fondo + " combinados en modo " + + ((modo && modo.textContent) || m.blendMode).toLowerCase() + "."; +} + function renderMixer() { if (state.engine !== "mixer") return; const m = state.mixer; + const esButter = m.fondo === "butter"; + const usaFondo = m.source === "cam" || m.source === "mix"; + const usaClip = m.source === "video" || m.source === "mix"; + $$("#mixer-source .segbtn").forEach((b) => b.classList.toggle("active", b.dataset.src === m.source)); $("#mix-cam").checked = m.camOn; fillSelectOnce($("#mix-camera"), state.meta.cameraDevices, (d) => d.id, (d) => d.name); $("#mix-camera").value = m.cameraId; - fillSelectOnce($("#mix-video"), (state.meta.videos || []).map((v) => ({ v })), - (o) => o.v, (o) => o.v); + fillClipSelect($("#mix-video")); $("#mix-video").value = m.video || ""; $("#mix-fondo").value = m.fondo || "cam"; $("#mix-blend").value = m.blendMode; $("#mix-invert").checked = m.invert; $("#mix-beat").checked = m.beatPulse; + + // Cada capa ensena solo sus propios controles: con fondo Butter la camara + // no pinta nada (se apaga de verdad en el escenario) y al reves. + $("#mix-cam-block").hidden = esButter; + $("#mix-bg-block").hidden = !esButter; + // El modo de mezcla solo tiene sentido si se ven las dos capas a la vez + $("#mix-blend-row").hidden = m.source !== "mix"; + // Lo que no se esta usando queda atenuado, no escondido + $("#mix-fondo-row").classList.toggle("dim", !usaFondo); + $("#mix-cam-block").classList.toggle("dim", !usaFondo); + $("#mix-bg-block").classList.toggle("dim", !usaFondo); + $("#mix-clip-label").classList.toggle("dim", !usaClip); + $("#mix-video").classList.toggle("dim", !usaClip); + + // Preset de MilkDrop que hace de fondo (el mismo del motor Butter) + if (esButter) { + const b = state.butterchurn; + fillSelectOnce($("#mix-bg-preset"), state.meta.butterchurnPresets, + (n) => n, (n) => n); + if (b.preset && document.activeElement !== $("#mix-bg-preset")) { + $("#mix-bg-preset").value = b.preset; + } + $("#mix-bg-shuffle").checked = b.shuffle; + } + MIXER_SLIDERS.forEach((cfg) => { const input = $("#ms-" + cfg.key); if (input && document.activeElement !== input) { @@ -295,6 +522,13 @@ function renderMixer() { $("#mv-" + cfg.key).textContent = fmt(m[cfg.key], cfg.step); } }); + // En modo recorte el deslizador de mezcla no funde: fija el umbral + $("#ml-mix").textContent = (m.source === "mix" && m.blendMode === "recorte") + ? "Umbral del recorte" : "Mezcla A/B"; + $("#mv-mix").parentElement.parentElement.classList.toggle("dim", + m.source !== "mix"); + + $("#mix-resumen").textContent = mixerResumen(m); } function renderProjectM() { @@ -429,15 +663,52 @@ const MAP_MAXN = 6; const sidx = (c, r, n) => r * (n + 1) + c; const cl01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); +/* Normaliza una capa: geometria a formato malla y ajustes con sus valores por + defecto. Tiene que coincidir con migrate() de stage/mapper.js. */ function mapMigrate(s) { - if (Array.isArray(s.points) && s.n) - return { id: s.id, n: s.n, points: s.points.map((p) => [p[0], p[1]]) }; - if (Array.isArray(s.corners)) { + const capa = { + id: s.id, + nombre: s.nombre || "", + visible: s.visible !== false, + opacidad: typeof s.opacidad === "number" ? s.opacidad : 1, + mezcla: s.mezcla === "sumar" ? "sumar" : "normal", + fuente: Object.assign({ tipo: "motor" }, s.fuente || {}), + }; + if (Array.isArray(s.points) && s.n) { + capa.n = s.n; capa.points = s.points.map((p) => [p[0], p[1]]); + } else if (Array.isArray(s.corners)) { const c = s.corners; - return { id: s.id, n: 1, points: [[c[0][0], c[0][1]], [c[1][0], c[1][1]], - [c[3][0], c[3][1]], [c[2][0], c[2][1]]] }; // TL,TR,BL,BR + capa.n = 1; + capa.points = [[c[0][0], c[0][1]], [c[1][0], c[1][1]], + [c[3][0], c[3][1]], [c[2][0], c[2][1]]]; // TL,TR,BL,BR + } else { + capa.n = 1; + capa.points = [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]]; } - return { id: s.id, n: 1, points: [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]] }; + return capa; +} + +// Etiqueta corta de la fuente de una capa (la que sale en la lista) +const FUENTE_NOMBRE = { + motor: "El motor de MOTORES", butter: "MilkDrop propio", + shader: "Shader GLSL", video: "Clip de la galeria", + camara: "Camara", negro: "Negro (tapa)", +}; +function fuenteTexto(f) { + f = f || {}; + const base = FUENTE_NOMBRE[f.tipo] || FUENTE_NOMBRE.motor; + if (f.tipo === "butter") { + if (f.biblioteca === "projectm") { + return "projectM" + (f.milk + ? " · " + f.milk.split("/").pop().replace(/\.milk$/i, "") + : " · sin preset elegido"); + } + return base + (f.auto ? " · cambio automatico" + : (f.preset ? " · " + f.preset : "")); + } + if (f.tipo === "video" && f.archivo) return base + " · " + etiquetaClip(f.archivo); + if (f.tipo === "shader" && f.shader) return base + " · " + f.shader; + return base; } function mapSample(s, u, v) { const n = s.n; @@ -454,7 +725,8 @@ function mapResample(s, newN) { const pts = []; for (let r = 0; r <= newN; r++) for (let c = 0; c <= newN; c++) pts.push(mapSample(s, c / newN, r / newN)); - return { id: s.id, n: newN, points: pts }; + // Solo cambia la malla: los ajustes de la capa se conservan + return Object.assign(mapMigrate(s), { n: newN, points: pts }); } function mapPointInQuad(x, y, c) { let inside = false; @@ -470,6 +742,18 @@ function mapOutline(s) { } const mapSelSurf = () => (mapSel && mapSel.type === "surface") ? mapSurfaces.find((s) => s.id === mapSel.id) : null; +/* Rotulo centrado que no se sale del lienzo: si la capa esta pegada a un + borde, el texto se recorta y se empuja hacia dentro. */ +function rotulo(ctx, txt, x, y, w) { + let t = txt; + while (t.length > 6 && ctx.measureText(t).width > w - 12) { + t = t.slice(0, -2); + } + if (t !== txt) t = t.slice(0, -1) + "…"; + const media = ctx.measureText(t).width / 2; + ctx.fillText(t, Math.min(Math.max(x, media + 5), w - media - 5), y); +} + function drawMapPreview() { const cv = $("#map-preview"); if (!cv) return; @@ -486,7 +770,7 @@ function drawMapPreview() { ctx.textAlign = "center"; ctx.textBaseline = "middle"; if (!mapSurfaces.length && !mapMasks.length) { ctx.fillStyle = "rgba(255,255,255,0.4)"; - ctx.fillText("Pulsa '+ Superficie'", w / 2, h / 2); + ctx.fillText("Pulsa '+ Capa'", w / 2, h / 2); return; } // superficies (malla n x n) @@ -494,13 +778,28 @@ function drawMapPreview() { const n = s.n; const sel = mapSel && mapSel.type === "surface" && mapSel.id === s.id; const col = sel ? "#00e5ff" : "#b4ff00"; + ctx.globalAlpha = s.visible ? 1 : 0.35; const P = (c, r) => { const p = s.points[sidx(c, r, n)]; return [p[0] * w, p[1] * h]; }; ctx.strokeStyle = sel ? "rgba(0,229,255,0.45)" : "rgba(180,255,0,0.3)"; ctx.lineWidth = 1; for (let r = 0; r <= n; r++) { ctx.beginPath(); for (let c = 0; c <= n; c++) { const p = P(c, r); c ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); } for (let c = 0; c <= n; c++) { ctx.beginPath(); for (let r = 0; r <= n; r++) { const p = P(c, r); r ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); } - const cc = P((n / 2) | 0, (n / 2) | 0); - ctx.fillStyle = col; ctx.fillText(String(i + 1), cc[0], cc[1]); + // Nombre en el centro de verdad (el medio de las cuatro esquinas). La + // linea de la fuente solo en la capa seleccionada: con varias capas + // solapadas, los rotulos se pisaban unos a otros y no se leia nada. + const esq = [P(0, 0), P(n, 0), P(n, n), P(0, n)]; + const cc = [(esq[0][0] + esq[1][0] + esq[2][0] + esq[3][0]) / 4, + (esq[0][1] + esq[1][1] + esq[2][1] + esq[3][1]) / 4]; + ctx.shadowColor = "#000"; ctx.shadowBlur = 5; // legible sobre la malla + ctx.fillStyle = col; + rotulo(ctx, s.nombre || "Capa " + (i + 1), cc[0], sel ? cc[1] - 8 : cc[1], w); + if (sel) { + ctx.font = "11px system-ui, sans-serif"; + ctx.fillStyle = "rgba(0,229,255,0.85)"; + rotulo(ctx, fuenteTexto(s.fuente), cc[0], cc[1] + 9, w); + ctx.font = "600 13px system-ui, sans-serif"; + } + ctx.shadowBlur = 0; for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) { const p = P(c, r); ctx.beginPath(); ctx.arc(p[0], p[1], MAP_H, 0, Math.PI * 2); @@ -559,13 +858,15 @@ function onMapDown(ev) { ev.preventDefault(); const p = mapLocalPt(ev); const hit = mapPick(p); - if (!hit) { mapSel = null; mapDrag = null; drawMapPreview(); updateMapSelBar(); return; } + // Tocar fuera deselecciona; tocar dentro de una capa la selecciona y + // ensena sus propiedades (asi se elige la fuente sin buscarla en la lista). + if (!hit) { mapSel = null; mapDrag = null; drawMapPreview(); renderCapas(); renderPropiedades(); return; } mapSel = { type: hit.type, id: hit.id }; const arr = hit.type === "surface" ? mapSurfaces.find((s) => s.id === hit.id).points : mapMasks.find((m) => m.id === hit.id).corners; mapDrag = { type: hit.type, id: hit.id, pt: hit.pt, sx: p.x, sy: p.y, orig: arr.map((q) => q.slice()) }; - drawMapPreview(); updateMapSelBar(); + drawMapPreview(); renderCapas(); renderPropiedades(); } function onMapMove(ev) { if (!mapDrag) return; @@ -591,51 +892,206 @@ function emitMapNow() { socket.emit("set_mapping", { surfaces: mapSurfaces, masks: mapMasks }); } -function updateMapSelBar() { - const bar = $("#map-selbar"); +/* Cambia ajustes de una capa y lo manda. Todo el panel de propiedades pasa + por aqui: modifica solo la capa seleccionada y deja el resto igual. */ +function patchCapa(id, patch) { + mapSurfaces = mapSurfaces.map((s) => + (s.id === id ? Object.assign({}, s, patch) : s)); + emitMapNow(); +} +function patchFuente(id, patch) { + const s = mapSurfaces.find((x) => x.id === id); + if (!s) return; + patchCapa(id, { fuente: Object.assign({}, s.fuente, patch) }); +} +function moverCapa(id, delta) { + const i = mapSurfaces.findIndex((s) => s.id === id); + const j = i + delta; + if (i < 0 || j < 0 || j >= mapSurfaces.length) return; + const arr = mapSurfaces.slice(); + arr.splice(j, 0, arr.splice(i, 1)[0]); + mapSurfaces = arr; + emitMapNow(); +} + +// ---------------------------------------------------------------- la lista +function renderCapas() { + const list = $("#map-list"); + const sig = mapSurfaces.map((s) => [s.id, s.nombre, s.visible, s.n, + fuenteTexto(s.fuente)].join(",")).join("|") + + "##" + mapMasks.map((k) => k.id).join("|") + + "##" + (mapSel ? mapSel.type + mapSel.id : ""); + $("#map-list-vacio").hidden = !!(mapSurfaces.length || mapMasks.length); + if (list.dataset.sig === sig) return; + list.dataset.sig = sig; + list.innerHTML = ""; + + const fila = (opciones) => { + const row = document.createElement("div"); + row.className = "capa" + (opciones.sel ? " sel" : "") + + (opciones.oculta ? " oculta" : "") + + (opciones.mascara ? " mascara" : ""); + row.addEventListener("click", opciones.onSel); + const txt = document.createElement("div"); + txt.className = "txt"; + txt.innerHTML = ''; + txt.querySelector(".nom").textContent = opciones.nombre; + txt.querySelector(".src").textContent = opciones.sub; + row.appendChild(txt); + (opciones.botones || []).forEach((b) => { + const el = document.createElement("button"); + el.textContent = b.txt; + el.title = b.titulo || ""; + if (b.clase) el.className = b.clase; + if (b.off) el.disabled = true; + el.addEventListener("click", (e) => { e.stopPropagation(); b.fn(); }); + row.appendChild(el); + }); + list.appendChild(row); + }; + + // Las capas se pintan de arriba abajo: la ultima de la lista queda encima + // en el proyector, asi que la ensenamos al reves (como en cualquier VJ). + mapSurfaces.slice().reverse().forEach((s, ri) => { + const i = mapSurfaces.length - 1 - ri; + fila({ + nombre: s.nombre || "Capa " + (i + 1), + sub: fuenteTexto(s.fuente) + " · malla " + s.n + "x" + s.n, + sel: mapSel && mapSel.type === "surface" && mapSel.id === s.id, + oculta: !s.visible, + onSel: () => { mapSel = { type: "surface", id: s.id }; renderMapping(); }, + botones: [ + { txt: s.visible ? "◉" : "○", titulo: "Ver / ocultar", + fn: () => patchCapa(s.id, { visible: !s.visible }) }, + { txt: "▲", titulo: "Subir (mas al frente)", off: i === mapSurfaces.length - 1, + fn: () => moverCapa(s.id, 1) }, + { txt: "▼", titulo: "Bajar (mas al fondo)", off: i === 0, + fn: () => moverCapa(s.id, -1) }, + { txt: "✕", titulo: "Borrar capa", clase: "del", fn: () => { + mapSurfaces = mapSurfaces.filter((x) => x.id !== s.id); + if (mapSel && mapSel.id === s.id) mapSel = null; + emitMapNow(); + } }, + ], + }); + }); + + mapMasks.forEach((k, i) => fila({ + nombre: "Mascara " + (i + 1), + sub: "Recuadro negro que tapa", + sel: mapSel && mapSel.type === "mask" && mapSel.id === k.id, + onSel: () => { mapSel = { type: "mask", id: k.id }; renderMapping(); }, + mascara: true, + botones: [{ txt: "✕", titulo: "Borrar mascara", clase: "del", fn: () => { + mapMasks = mapMasks.filter((x) => x.id !== k.id); + if (mapSel && mapSel.id === k.id) mapSel = null; + emitMapNow(); + } }], + })); +} + +// ---------------------------------------------------------- las propiedades +function renderPropiedades() { const s = mapSelSurf(); - if (!s) { bar.hidden = true; return; } - bar.hidden = false; + const esMascara = mapSel && mapSel.type === "mask"; + $("#prop-vacio").hidden = !!(s || esMascara); + $("#prop-capa").hidden = !s; + $("#prop-mascara").hidden = !esMascara; + const i = s ? mapSurfaces.findIndex((x) => x.id === s.id) : -1; + $("#prop-titulo").textContent = s + ? (s.nombre || "Capa " + (i + 1)) + : (esMascara ? "Mascara" : "Propiedades"); + if (!s) return; + + const f = s.fuente; + if (document.activeElement !== $("#prop-nombre")) { + $("#prop-nombre").value = s.nombre; + $("#prop-nombre").placeholder = "Capa " + (i + 1); + } + $("#prop-fuente").value = f.tipo; + $$(".fuente-op").forEach((el) => { el.hidden = true; }); + const bloque = $("#fop-" + f.tipo); + if (bloque) bloque.hidden = false; + + if (f.tipo === "butter") { + const esMilk = f.biblioteca === "projectm"; + $("#prop-biblioteca").value = esMilk ? "projectm" : "butter"; + $("#fop-bib-butter").hidden = esMilk; + $("#fop-bib-milk").hidden = !esMilk; + if (esMilk) renderMilkCapa(f); + + fillSelectOnce($("#prop-preset"), state.meta.butterchurnPresets, + (n) => n, (n) => n); + if (f.preset && document.activeElement !== $("#prop-preset")) { + $("#prop-preset").value = f.preset; + } + $("#prop-auto").checked = !!f.auto; + $("#prop-auto-row").hidden = !f.auto; + $("#prop-seg").hidden = !f.auto; + $("#prop-seg").value = f.segundos || 20; + $("#prop-seg-val").textContent = f.segundos || 20; + // Con el cambio automatico puesto manda el reloj: elegir preset a mano + // no haria nada, asi que se apaga en vez de mentir. Con la biblioteca de + // projectM el preset va clavado, asi que el automatico tampoco aplica. + $("#prop-auto").disabled = esMilk; + $("#prop-preset").disabled = !!f.auto; + $("#prop-preset-prev").disabled = !!f.auto; + $("#prop-preset-next").disabled = !!f.auto; + const tr = typeof f.transicion === "number" ? f.transicion : 2.5; + if (document.activeElement !== $("#prop-transicion")) { + $("#prop-transicion").value = tr; + } + $("#prop-tr-val").textContent = Number(tr).toFixed(1); + } else if (f.tipo === "shader") { + fillSelectOnce($("#prop-shader"), shaderLib, (x) => x.id, (x) => x.name); + $("#prop-shader").value = f.shader || ""; + } else if (f.tipo === "video") { + fillClipSelect($("#prop-video")); + $("#prop-video").value = f.archivo || ""; + } else if (f.tipo === "camara") { + fillSelectOnce($("#prop-camara"), state.meta.cameraDevices, + (d) => d.id, (d) => d.name); + $("#prop-camara").value = f.camaraId || 0; + } + + if (document.activeElement !== $("#prop-opacidad")) { + $("#prop-opacidad").value = s.opacidad; + } + $("#prop-op-val").textContent = Math.round(s.opacidad * 100); + $("#prop-mezcla").value = s.mezcla; $("#map-sub-val").textContent = s.n + "x" + s.n; } -// refleja estado, dibuja previsualizacion y listas (superficies + mascaras) +// refleja estado, dibuja previsualizacion, lista de capas y propiedades function renderMapping() { const m = state.mapping || { enabled: false, edit: false, surfaces: [], masks: [] }; const en = $("#map-enabled"), ed = $("#map-edit"); if (document.activeElement !== en) en.checked = !!m.enabled; if (document.activeElement !== ed) ed.checked = !!m.edit; ed.disabled = !m.enabled; - const surfaces = m.surfaces || [], masks = m.masks || []; - if (!mapDrag) { - mapSurfaces = surfaces.map(mapMigrate); - mapMasks = masks.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) })); + // Mientras se arrastra, se escribe un nombre o queda un envio de nombre + // pendiente, no pisamos la copia local: el estado que llega todavia es el + // de antes y borraria lo que se acaba de escribir. + const escribiendo = document.activeElement === $("#prop-nombre") || !!nombreTimer; + if (!mapDrag && !escribiendo) { + mapSurfaces = (m.surfaces || []).filter((s) => s && s.id).map(mapMigrate); + // Una mascara sin esquinas (mapping.json raro) se descarta en vez de + // reventar el render del panel entero. + mapMasks = (m.masks || []) + .filter((k) => k && Array.isArray(k.corners) && k.corners.length === 4) + .map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) })); if (mapSel) { - const ok = mapSel.type === "surface" ? mapSurfaces.some((s) => s.id === mapSel.id) : mapMasks.some((k) => k.id === mapSel.id); + const ok = mapSel.type === "surface" + ? mapSurfaces.some((s) => s.id === mapSel.id) + : mapMasks.some((k) => k.id === mapSel.id); if (!ok) mapSel = null; } } drawMapPreview(); - updateMapSelBar(); - - // lista (superficies + mascaras) con borrar - const list = $("#map-list"); - const sig = surfaces.map((s) => s.id + ":" + (s.n || 1)).join("|") + "##" + masks.map((k) => k.id).join("|"); - if (list.dataset.sig === sig) return; - list.dataset.sig = sig; - list.innerHTML = ""; - const addRow = (label, onDel) => { - const row = document.createElement("div"); row.className = "row"; - const lbl = document.createElement("span"); lbl.className = "label"; lbl.textContent = label; - const del = document.createElement("button"); del.className = "cmd favdel"; del.textContent = "✕"; - del.addEventListener("click", onDel); - row.appendChild(lbl); row.appendChild(del); list.appendChild(row); - }; - surfaces.forEach((s, i) => addRow("Superficie " + (i + 1) + " (" + (s.n || 1) + "x" + (s.n || 1) + ")", - () => socket.emit("set_mapping", { surfaces: surfaces.filter((x) => x.id !== s.id) }))); - masks.forEach((k, i) => addRow("Mascara " + (i + 1), - () => socket.emit("set_mapping", { masks: masks.filter((x) => x.id !== k.id) }))); + renderCapas(); + renderTabsCapas(); + renderPropiedades(); } // ========================================================================== @@ -649,37 +1105,312 @@ $("#map-enabled").addEventListener("change", (e) => socket.emit("set_mapping", { enabled: e.target.checked })); $("#map-edit").addEventListener("change", (e) => socket.emit("set_mapping", { edit: e.target.checked })); -$("#map-add").addEventListener("click", () => { - const cur = (state.mapping && state.mapping.surfaces) || []; - const s = 0.2, cx = 0.5, cy = 0.5; +/* Capa nueva: cae escalonada para que dos seguidas no se solapen del todo, + y queda seleccionada para poder elegir su fuente al momento. */ +/* Crea una capa y devuelve su id. Cae escalonada para que dos seguidas no se + solapen del todo. */ +function nuevaCapa() { + const n = mapSurfaces.length; + const s = 0.18; + const cx = cl01(0.3 + (n % 3) * 0.2), cy = cl01(0.35 + ((n / 3) | 0) * 0.2); const nueva = { id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4), n: 1, + nombre: "", visible: true, opacidad: 1, mezcla: "normal", + fuente: fuenteDeCapaNueva(), points: [[cx - s, cy - s], [cx + s, cy - s], [cx - s, cy + s], [cx + s, cy + s]], // TL,TR,BL,BR }; - socket.emit("set_mapping", { enabled: true, edit: true, surfaces: cur.concat([nueva]) }); + mapSurfaces = mapSurfaces.concat([nueva]); + mapSel = { type: "surface", id: nueva.id }; + socket.emit("set_mapping", + { enabled: true, surfaces: mapSurfaces, masks: mapMasks }); + return nueva.id; +} + +$("#map-add").addEventListener("click", () => { + nuevaCapa(); + renderCapas(); renderTabsCapas(); renderPropiedades(); drawMapPreview(); +}); + +/* "+ CAPA" de la barra de arriba: crea la capa y te deja YA dentro de su + pestana, que es lo que hace que trabajar con varias capas no sea un + suplicio de ir y volver a MAPPING. */ +$("#tab-nueva-capa").addEventListener("click", () => { + const id = nuevaCapa(); + renderCapas(); renderTabsCapas(); + setView("capa:" + id); }); $("#map-add-mask").addEventListener("click", () => { - const cur = (state.mapping && state.mapping.masks) || []; const s = 0.15, cx = 0.5, cy = 0.5; const nueva = { id: "m" + Date.now() + "_" + Math.floor(Math.random() * 1e4), corners: [[cx - s, cy - s], [cx + s, cy - s], [cx + s, cy + s], [cx - s, cy + s]], // TL,TR,BR,BL }; - socket.emit("set_mapping", { enabled: true, edit: true, masks: cur.concat([nueva]) }); + mapMasks = mapMasks.concat([nueva]); + mapSel = { type: "mask", id: nueva.id }; + socket.emit("set_mapping", + { enabled: true, surfaces: mapSurfaces, masks: mapMasks }); + renderCapas(); renderPropiedades(); drawMapPreview(); }); $("#map-reset").addEventListener("click", () => { - if (confirm("Borrar todas las superficies y mascaras de mapping?")) + if (confirm("Borrar todas las capas y mascaras del mapping?")) { + mapSurfaces = []; mapMasks = []; mapSel = null; socket.emit("set_mapping", { surfaces: [], masks: [] }); + } }); $("#map-sub-inc").addEventListener("click", () => { const s = mapSelSurf(); if (!s || s.n >= MAP_MAXN) return; - const ns = mapResample(s, s.n + 1); - socket.emit("set_mapping", { surfaces: mapSurfaces.map((x) => x.id === s.id ? ns : x) }); + mapSurfaces = mapSurfaces.map((x) => (x.id === s.id ? mapResample(s, s.n + 1) : x)); + emitMapNow(); }); $("#map-sub-dec").addEventListener("click", () => { const s = mapSelSurf(); if (!s || s.n <= 1) return; - const ns = mapResample(s, s.n - 1); - socket.emit("set_mapping", { surfaces: mapSurfaces.map((x) => x.id === s.id ? ns : x) }); + mapSurfaces = mapSurfaces.map((x) => (x.id === s.id ? mapResample(s, s.n - 1) : x)); + emitMapNow(); +}); + +// --- Propiedades de la capa seleccionada --- +const selId = () => (mapSel && mapSel.type === "surface" ? mapSel.id : null); + +// El nombre se guarda con calma: cada pulsacion escribe mapping.json en el +// disco, asi que no mandamos una por tecla. +let nombreTimer = null; +$("#prop-nombre").addEventListener("input", (e) => { + const id = selId(); if (!id) return; + mapSurfaces = mapSurfaces.map((s) => + (s.id === id ? Object.assign({}, s, { nombre: e.target.value }) : s)); + clearTimeout(nombreTimer); + nombreTimer = setTimeout(() => { nombreTimer = null; emitMapNow(); }, 400); +}); +$("#prop-nombre").addEventListener("change", () => { renderCapas(); drawMapPreview(); }); + +/* Al cambiar de fuente estrenamos ajustes con algo que se vea ya: un preset al + azar, el primer shader o el primer clip de la lista. Elegir "MilkDrop" y + quedarse en negro esperando a rellenar otro desplegable es de las cosas que + mas despistan. */ +function fuenteEstrenada(tipo) { + const f = { tipo: tipo }; + if (tipo === "butter") { + const ps = (state.meta && state.meta.butterchurnPresets) || []; + f.preset = ps.length ? ps[Math.floor(Math.random() * ps.length)] : ""; + f.biblioteca = "butter"; f.milk = ""; + f.auto = false; f.segundos = 20; f.transicion = 2.5; + } else if (tipo === "shader") { + f.shader = shaderLib.length ? shaderLib[0].id : ""; + } else if (tipo === "video") { + const vs = (state.meta && state.meta.videos) || []; + f.archivo = vs.length ? vs[0] : ""; + } else if (tipo === "camara") { + f.camaraId = 0; + } + return f; +} + +/* Fuente con la que nace una capa nueva. NO puede ser "motor": esa fuente es + literalmente el lienzo del motor activo, asi que dos capas recien creadas + ensenaban la misma imagen y parecia que las capas no fueran independientes. + Nace en MilkDrop con preset al azar — contenido distinto desde el primer + clic — y solo cae en "motor" si Butterchurn aun no ha dado su lista. */ +function fuenteDeCapaNueva() { + const ps = (state.meta && state.meta.butterchurnPresets) || []; + return ps.length ? fuenteEstrenada("butter") : { tipo: "motor" }; +} + +$("#prop-fuente").addEventListener("change", (e) => { + const id = selId(); if (!id) return; + patchCapa(id, { fuente: fuenteEstrenada(e.target.value) }); + renderCapas(); renderPropiedades(); drawMapPreview(); +}); + +/* --- Biblioteca de projectM dentro de una capa --- + La ruta de un preset es "Categoria/Visual/Variante.milk"; el escenario la + pide al backend y la traduce a Butterchurn al vuelo (ver layers.js). Aqui + solo se elige. Las categorias y visuales ya vienen en el estado; las + variantes se piden al servidor al cambiar de visual, igual que hace el + motor projectM. */ +function partesMilk(ruta) { + const p = String(ruta || "").split("/"); + return { cat: p[0] || "", vis: p[1] || "", + vari: (p[2] || "").replace(/\.milk$/i, "") }; +} + +function rellena(sel, items, vacio) { + const sig = vacio + "|" + items.join("|"); + if (sel.dataset.sig === sig) return false; + sel.dataset.sig = sig; + sel.innerHTML = ""; + const o0 = document.createElement("option"); + o0.value = ""; o0.textContent = vacio; + sel.appendChild(o0); + items.forEach((v) => { + const o = document.createElement("option"); + o.value = v; o.textContent = v; + sel.appendChild(o); + }); + return true; +} + +function renderMilkCapa(f) { + const lib = (state.meta && state.meta.projectmPresets) || {}; + const { cat, vis, vari } = partesMilk(f.milk); + const cats = Object.keys(lib); + + rellena($("#prop-milk-cat"), cats, "-- categoria --"); + $("#prop-milk-cat").value = cat; + + rellena($("#prop-milk-vis"), cat ? (lib[cat] || []) : [], + cat ? "-- visual --" : "-- elige antes una categoria --"); + $("#prop-milk-vis").value = vis; + + const varSel = $("#prop-milk-var"); + const sig = cat + "|" + vis; + if (varSel.dataset.pedido !== sig) { + varSel.dataset.pedido = sig; + rellena(varSel, [], cat && vis ? "cargando variantes..." + : "-- elige antes un visual --"); + if (cat && vis) { + fetch("/pm/variantes?categoria=" + encodeURIComponent(cat) + + "&visual=" + encodeURIComponent(vis)) + .then((r) => r.json()) + .then((d) => { + if (varSel.dataset.pedido !== sig) return; // cambio a medias + varSel.dataset.sig = ""; // forzar repintado + rellena(varSel, d.variantes || [], "-- variante --"); + const s = mapSelSurf(); + varSel.value = partesMilk(s && s.fuente && s.fuente.milk).vari; + }) + .catch(() => rellena(varSel, [], "-- no se pudieron leer --")); + } + } else if (document.activeElement !== varSel) { + varSel.value = vari; + } + + const est = $("#prop-milk-est"); + if (est) { + est.textContent = f.milk + ? "En esta capa: " + f.milk.replace(/\.milk$/i, "") + : "Elige categoria, visual y variante — o pulsa 'Uno al azar'."; + } +} + +/* Pone en la capa el .milk que toque, con lo que haya elegido de cada nivel. + Sin variante no hay preset que cargar: se coge la primera del visual. */ +function ponMilk(cat, vis, vari) { + const id = selId(); if (!id) return; + if (!cat || !vis) { patchFuente(id, { biblioteca: "projectm", milk: "" }); return; } + if (vari) { + patchFuente(id, { biblioteca: "projectm", milk: `${cat}/${vis}/${vari}.milk` }); + return; + } + fetch("/pm/variantes?categoria=" + encodeURIComponent(cat) + + "&visual=" + encodeURIComponent(vis)) + .then((r) => r.json()) + .then((d) => { + const v = (d.variantes || [])[0]; + patchFuente(id, { biblioteca: "projectm", + milk: v ? `${cat}/${vis}/${v}.milk` : "" }); + }) + .catch(() => {}); +} + +$("#prop-biblioteca").addEventListener("change", (e) => { + const id = selId(); if (!id) return; + if (e.target.value === "projectm") { + // Estrenar con algo que se vea: si no habia .milk, uno al azar + const s = mapSelSurf(); + if (s && s.fuente && s.fuente.milk) patchFuente(id, { biblioteca: "projectm" }); + else { patchFuente(id, { biblioteca: "projectm", auto: false }); milkAzar(); } + } else { + patchFuente(id, { biblioteca: "butter" }); + } + renderPropiedades(); +}); + +$("#prop-milk-cat").addEventListener("change", (e) => + ponMilk(e.target.value, "", "")); +$("#prop-milk-vis").addEventListener("change", (e) => + ponMilk($("#prop-milk-cat").value, e.target.value, "")); +$("#prop-milk-var").addEventListener("change", (e) => + ponMilk($("#prop-milk-cat").value, $("#prop-milk-vis").value, e.target.value)); + +function milkAzar() { + const lib = (state.meta && state.meta.projectmPresets) || {}; + const cats = Object.keys(lib).filter((c) => (lib[c] || []).length); + if (!cats.length) { + showNotif({ level: "warn", message: "No hay biblioteca de projectM " + + "instalada: data/presets-projectm esta vacia." }); + return; + } + const cat = cats[Math.floor(Math.random() * cats.length)]; + const viss = lib[cat]; + ponMilk(cat, viss[Math.floor(Math.random() * viss.length)], ""); +} +$("#prop-milk-azar").addEventListener("click", milkAzar); + +$("#prop-preset").addEventListener("change", (e) => { + const id = selId(); if (id) patchFuente(id, { preset: e.target.value }); +}); +function pasoPresetCapa(delta) { + const id = selId(); const s = mapSelSurf(); + const ps = (state && state.meta.butterchurnPresets) || []; + if (!id || !s || !ps.length) return; + const i = ps.indexOf(s.fuente.preset); + patchFuente(id, { preset: ps[((i < 0 ? 0 : i) + delta + ps.length) % ps.length] }); +} +$("#prop-preset-prev").addEventListener("click", () => pasoPresetCapa(-1)); +$("#prop-preset-next").addEventListener("click", () => pasoPresetCapa(1)); +$("#prop-auto").addEventListener("change", (e) => { + const id = selId(); if (id) patchFuente(id, { auto: e.target.checked }); +}); +$("#prop-seg").addEventListener("input", (e) => + $("#prop-seg-val").textContent = e.target.value); +$("#prop-seg").addEventListener("change", (e) => { + const id = selId(); + if (id) patchFuente(id, { segundos: parseInt(e.target.value, 10) }); +}); +$("#prop-transicion").addEventListener("input", (e) => + $("#prop-tr-val").textContent = Number(e.target.value).toFixed(1)); +$("#prop-transicion").addEventListener("change", (e) => { + const id = selId(); + if (id) patchFuente(id, { transicion: parseFloat(e.target.value) }); +}); +$("#prop-shader").addEventListener("change", (e) => { + const id = selId(); if (id) patchFuente(id, { shader: e.target.value }); +}); +$("#prop-video").addEventListener("change", (e) => { + const id = selId(); if (id) patchFuente(id, { archivo: e.target.value }); +}); +$("#prop-camara").addEventListener("change", (e) => { + const id = selId(); if (id) patchFuente(id, { camaraId: parseInt(e.target.value, 10) }); +}); +$("#prop-opacidad").addEventListener("input", (e) => + $("#prop-op-val").textContent = Math.round(parseFloat(e.target.value) * 100)); +$("#prop-opacidad").addEventListener("change", (e) => { + const id = selId(); + if (id) patchCapa(id, { opacidad: parseFloat(e.target.value) }); +}); +$("#prop-mezcla").addEventListener("change", (e) => { + const id = selId(); if (id) patchCapa(id, { mezcla: e.target.value }); +}); + +/* Convierte la mascara seleccionada en una capa en el mismo sitio. Es el + error mas facil de cometer: pulsar "+ Mascara" buscando donde poner un + visual. En vez de dejar el callejon sin salida, se sale de el. */ +$("#mask-a-capa").addEventListener("click", () => { + if (!mapSel || mapSel.type !== "mask") return; + const m = mapMasks.find((k) => k.id === mapSel.id); + if (!m) return; + const c = m.corners; // TL,TR,BR,BL -> la capa los quiere TL,TR,BL,BR + const nueva = { + id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4), n: 1, + nombre: "", visible: true, opacidad: 1, mezcla: "normal", + fuente: fuenteDeCapaNueva(), + points: [c[0].slice(), c[1].slice(), c[3].slice(), c[2].slice()], + }; + mapMasks = mapMasks.filter((k) => k.id !== m.id); + mapSurfaces = mapSurfaces.concat([nueva]); + mapSel = { type: "surface", id: nueva.id }; + emitMapNow(); + renderCapas(); renderPropiedades(); drawMapPreview(); }); // Previsualizacion arrastrable (raton + tactil via pointer events) (function () { @@ -792,6 +1523,25 @@ $("#mix-blend").addEventListener("change", (e) => socket.emit("update_settings", { engine: "mixer", patch: { blendMode: e.target.value } })); $("#mix-rescan").addEventListener("click", () => socket.emit("rescan_videos")); +// Fondo de visuales Butter: es el mismo preset del motor Butter, asi que se +// cambia por el estado (no con un comando al escenario) y queda elegido en +// los dos sitios. +$("#mix-bg-preset").addEventListener("change", (e) => + socket.emit("update_settings", + { engine: "butterchurn", patch: { preset: e.target.value } })); +$("#mix-bg-shuffle").addEventListener("change", (e) => + socket.emit("update_settings", + { engine: "butterchurn", patch: { shuffle: e.target.checked } })); +function pasoFondo(delta) { + const names = (state && state.meta.butterchurnPresets) || []; + if (!names.length) return; + const i = names.indexOf(state.butterchurn.preset); + const next = ((i < 0 ? 0 : i) + delta + names.length) % names.length; + socket.emit("update_settings", + { engine: "butterchurn", patch: { preset: names[next] } }); +} +$("#mix-bg-prev").addEventListener("click", () => pasoFondo(-1)); +$("#mix-bg-next").addEventListener("click", () => pasoFondo(1)); $("#mix-invert").addEventListener("change", (e) => socket.emit("update_settings", { engine: "mixer", patch: { invert: e.target.checked } })); @@ -837,7 +1587,7 @@ $("#mix-upload").addEventListener("change", () => { const fd = new FormData(); fd.append("file", f); const xhr = new XMLHttpRequest(); - xhr.open("POST", "/upload"); + xhr.open("POST", "/upload" + (CLAVE ? "?k=" + encodeURIComponent(CLAVE) : "")); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { status.textContent = "subiendo " + Math.round(e.loaded / e.total * 100) + "%"; diff --git a/web/stage/index.html b/web/stage/index.html index 1a834ce..f108a8f 100644 --- a/web/stage/index.html +++ b/web/stage/index.html @@ -70,7 +70,11 @@ + + + diff --git a/web/stage/layers.js b/web/stage/layers.js new file mode 100644 index 0000000..874882a --- /dev/null +++ b/web/stage/layers.js @@ -0,0 +1,457 @@ +/* + * FOSFENO :: Capas del mapping + * -------------------------------------------------------------------------- + * Cada superficie del mapping puede llevar su PROPIA fuente: una instancia de + * Butterchurn con su preset, un shader GLSL, un clip de la galeria, la camara + * o el motor que este activo. Este modulo crea esas fuentes, las mantiene y + * le entrega al mapper el elemento que tiene que usar como textura. + * + * Fuente (va dentro de cada superficie, en data/mapping.json): + * { tipo: "motor" } el motor elegido en el panel + * { tipo: "butter", preset, auto, segundos } instancia propia de MilkDrop + * biblioteca: "butter" (los ~100 que trae Butterchurn) o "projectm" + * (los .milk de data/presets-projectm); con "projectm", milk lleva la + * ruta relativa del preset ("Dancer/Glowsticks/285.milk") + * { tipo: "shader", shader } shader GLSL de la libreria + * { tipo: "video", archivo } clip o imagen de data/videos + * { tipo: "camara", camaraId } webcam + * { tipo: "negro" } nada (tapa la zona) + * + * Cada capa se renderiza en un lienzo propio y PEQUENO (el mapper la estira + * al deformarla), que es lo que hace viable tener varias a la vez en una + * Raspberry. Las instancias con estado (Butterchurn, shaders) son una por + * superficie; las que no lo tienen (clips, camara) se comparten. + * + * API global: + * FosLayers.init({ audioCtx, gainNode, analyser, crearShader, shaders, report, + * onPreset(surfaceId, preset) }) preset elegido al azar + * FosLayers.setMotor(canvas) lienzo del motor activo (fuente "motor") + * FosLayers.sync(surfaces) crea/actualiza/destruye segun el estado + * FosLayers.fuenteDe(surface) -> { el, clave } o null (lo usa el mapper) + * FosLayers.presets() nombres de preset disponibles + */ +(function () { + 'use strict'; + + const ANCHO = 640; // resolucion interna de cada capa (se estira luego) + const ALTO = 360; + const SEGUNDOS = 20; // cambio automatico de preset por defecto + + let deps = null; + let motorEl = null; + + // Instancias con estado: una por superficie (id de superficie -> capa) + const capas = new Map(); + // Fuentes compartidas sin estado: clip o camara (clave -> recurso) + const compartidas = new Map(); + + const IMAGEN_RE = /\.(jpe?g|png|gif|webp)$/i; + + function aviso(nivel, msg) { + if (deps && deps.report) deps.report(nivel, msg); + else console.warn('[FOSFENO] capas:', msg); + } + + function lienzo() { + const cv = document.createElement('canvas'); + cv.width = ANCHO; cv.height = ALTO; + return cv; + } + + /* Suelta de verdad el contexto WebGL de un lienzo que se tira. Sin esto, el + contexto sigue vivo hasta que pase el recolector: el navegador solo + aguanta unos 16 a la vez y al pasarse mata EL MAS VIEJO, que es el del + compositor de mapping. Resultado: proyector en negro para siempre. */ + function soltarGL(cv) { + try { + const g = cv.getContext('webgl2') || cv.getContext('webgl'); + const ext = g && g.getExtension('WEBGL_lose_context'); + if (ext) ext.loseContext(); + } catch (e) { /* el lienzo no llego a tener contexto */ } + } + + // La fuente de una superficie, siempre normalizada (las superficies + // antiguas no la llevan: son del motor activo, como hasta ahora). + function fuenteDe(s) { + const f = (s && s.fuente) || {}; + return { tipo: f.tipo || 'motor', preset: f.preset || '', auto: !!f.auto, + segundos: f.segundos || SEGUNDOS, shader: f.shader || '', + archivo: f.archivo || '', camaraId: f.camaraId || 0, + biblioteca: f.biblioteca === 'projectm' ? 'projectm' : 'butter', + milk: f.milk || '', + // segundos de fundido al pasar de un preset al siguiente + transicion: typeof f.transicion === 'number' ? f.transicion : 2.5 }; + } + + /* Firma de una fuente: si no cambia, la capa se deja viva y solo se le + pasan los ajustes nuevos con aplicar(). Para Butterchurn la firma es el + tipo a secas, a proposito: cambiar de preset o de cambio automatico NO + puede recrear la instancia, porque cada instancia nueva es un contexto + WebGL mas (ver soltarGL). */ + function firma(f) { + if (f.tipo === 'butter') return 'butter'; + if (f.tipo === 'shader') return 'shader|' + f.shader; + if (f.tipo === 'video') return 'video|' + f.archivo; + if (f.tipo === 'camara') return 'camara|' + f.camaraId; + return f.tipo; + } + + /* ------------------------------------------------- presets .milk (projectM) + La biblioteca gorda son los 9795 .milk de data/presets-projectm, que + projectM lee en nativo y Butterchurn no entiende. Se traducen aqui, en el + navegador, con milkdrop-preset-converter: se pide el .milk al backend (ya + los sirve tal cual), se convierte y se le pasa a viz.loadPreset como + cualquier otro preset. Tarda unos milisegundos y no gasta disco: no hay + que pre-convertir 9795 ficheros en la tarjeta de la Raspberry. + + La cache guarda la promesa, no el resultado: si dos capas piden el mismo + preset a la vez, se convierte una sola vez. */ + const milkCache = new Map(); + + function cargarMilk(ruta) { + if (milkCache.has(ruta)) return milkCache.get(ruta); + const conv = window.milkdropPresetConverter; + if (!conv || !conv.convertPreset) { + return Promise.reject(new Error( + 'falta la libreria de conversion de presets .milk')); + } + const p = fetch('/data/presets-projectm/' + + ruta.split('/').map(encodeURIComponent).join('/')) + .then((r) => { + if (!r.ok) throw new Error('no esta en la biblioteca (' + r.status + ')'); + return r.text(); + }) + .then((txt) => conv.convertPreset(txt)) + .then((preset) => { + if (!preset || !preset.baseVals) throw new Error('conversion vacia'); + return preset; + }) + .catch((e) => { milkCache.delete(ruta); throw e; }); // que se reintente + milkCache.set(ruta, p); + return p; + } + + // ---------------------------------------------------------------- Butter + function crearButter(f) { + if (typeof window.butterchurn === 'undefined' || !window.butterchurnPresets) { + aviso('warn', 'No hay Butterchurn: la capa de visuales MilkDrop se queda en negro.'); + return null; + } + const bch = window.butterchurn.default || window.butterchurn; + const bp = window.butterchurnPresets.default || window.butterchurnPresets; + const presets = bp.getPresets(); + const nombres = Object.keys(presets); + const cv = lienzo(); + let viz; + try { + viz = bch.createVisualizer(deps.audioCtx, cv, { + width: ANCHO, height: ALTO, pixelRatio: 1, + }); + viz.connectAudio(deps.gainNode); + } catch (e) { + aviso('error', 'No se pudo crear la capa de visuales MilkDrop: ' + e.message); + return null; + } + /* Sin preset elegido (capa recien creada antes de que la lista llegara al + panel), indexOf devuelve -1 y el Math.max lo dejaba en 0: TODAS las + capas asi arrancaban en el mismo preset y parecian la misma imagen. + Ahora cada instancia coge uno al azar y lo devuelve en presetElegido, + para que el panel lo ensene en vez de dejar el desplegable en blanco. */ + let i = nombres.indexOf(f.preset); + let presetElegido = ''; + if (i < 0) { + i = Math.floor(Math.random() * nombres.length); + presetElegido = nombres[i]; + } + let raf = null, timer = null, seg = 0, fundido = f.transicion; + + // El fundido lo hace el propio Butterchurn al cargar: a 0 es corte seco, + // a 6 segundos se disuelve uno en otro sin que se note el cambio. + const cargar = (nombre) => { + if (!presets[nombre]) return; + try { viz.loadPreset(presets[nombre], fundido); } catch (e) { /* preset roto */ } + }; + const paso = () => { i = (i + 1) % nombres.length; cargar(nombres[i]); }; + /* Monta o desmonta el cambio automatico sin tocar la instancia. */ + const ritmo = (auto, segundos) => { + const s = Math.max(2, segundos || SEGUNDOS); + if (!!timer === !!auto && (!auto || s === seg)) return; + clearInterval(timer); timer = null; seg = s; + if (auto) timer = setInterval(paso, s * 1000); + }; + + /* Carga un .milk de la biblioteca de projectM. Va por promesa, asi que + hay que descartar el resultado si mientras tanto la capa cambio de + preset o se destruyo: si no, el que tarde mas en convertirse pisa al + que el usuario acaba de elegir. */ + let milkActual = ''; // el que se pidio el ultimo + let muerta = false; + const cargarDeMilk = (ruta) => { + milkActual = ruta; + cargarMilk(ruta).then((preset) => { + if (muerta || milkActual !== ruta) return; + try { viz.loadPreset(preset, fundido); } + catch (e) { aviso('warn', 'El preset .milk "' + ruta + '" no se pudo ' + + 'cargar: ' + e.message); } + }).catch((e) => { + if (muerta || milkActual !== ruta) return; + aviso('warn', 'No se pudo convertir el preset "' + ruta + '": ' + + e.message + '. La capa se queda con el anterior.'); + }); + }; + + if (f.biblioteca === 'projectm' && f.milk) cargarDeMilk(f.milk); + else cargar(nombres[i]); + const bucle = () => { viz.render(); raf = requestAnimationFrame(bucle); }; + bucle(); + ritmo(f.auto, f.segundos); + + return { + el: cv, + // Con un .milk clavado no hay preset de Butterchurn que anotar + presetElegido: (f.biblioteca === 'projectm' && f.milk) ? '' : presetElegido, + // Todos los ajustes se aplican en caliente: nunca hay que recrear la + // instancia (y con ella, un contexto WebGL nuevo). + aplicar(nf) { + fundido = nf.transicion; + if (nf.biblioteca === 'projectm') { + // El cambio automatico es de la lista de Butterchurn: con un .milk + // clavado no tiene sentido dejarlo girando por encima. + if (nf.milk && nf.milk !== milkActual) cargarDeMilk(nf.milk); + ritmo(false, nf.segundos); + return; + } + if (milkActual) { // volvemos a la biblioteca de Butterchurn + milkActual = ''; + cargar(nombres[i]); + } + if (!nf.auto && nf.preset && nf.preset !== nombres[i]) { + const j = nombres.indexOf(nf.preset); + if (j >= 0) { i = j; cargar(nombres[i]); } + } + ritmo(nf.auto, nf.segundos); + }, + destruir() { + muerta = true; + if (raf) cancelAnimationFrame(raf); + clearInterval(timer); + // Sin desconectar, cada instancia tirada deja un analizador colgando + // del audio compartido para siempre. + try { viz.disconnectAudio(deps.gainNode); } catch (e) { /* ya suelto */ } + soltarGL(cv); + }, + }; + } + + // ---------------------------------------------------------------- Shader + function crearShaderCapa(f, nombre) { + const codigo = (deps.shaders && deps.shaders[f.shader] + && deps.shaders[f.shader].code) || ''; + if (!codigo) { + aviso('warn', 'La capa "' + nombre + '" es de shader pero no tiene ' + + 'ninguno elegido: seleccionalo en Propiedades.'); + return null; + } + const cv = lienzo(); + let eng; + try { + eng = deps.crearShader(cv, { width: ANCHO, height: ALTO }); + // Que no compile no puede dejar el contexto colgando (ver soltarGL) + if (!eng.load(codigo)) { + aviso('warn', 'El shader de la capa "' + nombre + '" no compila.'); + soltarGL(cv); + return null; + } + eng.start(); + } catch (e) { + aviso('error', 'La capa de shader "' + nombre + '" fallo: ' + e.message); + soltarGL(cv); + return null; + } + return { + el: cv, aplicar() {}, + destruir() { + try { eng.stop(); } catch (e) { /* ya parado */ } + soltarGL(cv); + }, + }; + } + + // ------------------------------------------------- Clip / imagen / camara + // Sin estado propio: varias superficies pueden compartir el mismo recurso. + function recursoCompartido(clave, crear) { + let r = compartidas.get(clave); + if (!r) { r = crear(); if (!r) return null; compartidas.set(clave, r); } + r.usos = (r.usos || 0) + 1; + return r; + } + + /* Ojo al soltar: vaciar el src de un