Capas independientes, projectM en el mapping y catalogo de la galeria
Mapping - Los clips salian del reves: mapper.js activaba UNPACK_FLIP_Y_WEBGL, pero el modelo va con origen arriba-izquierda y texImage2D ya sube la primera fila en t=0, asi que el flip la mandaba al final. Comprobado renderizando el mapper real en Chromium headless con una fuente mitad roja / mitad azul. - projectM se puede usar YA en una capa: los .milk de data/presets-projectm se traducen a Butterchurn en el navegador al elegirlos (milkdrop-preset- converter). La capa MilkDrop gana 'biblioteca' (butter | projectm) sin cambiar su firma, para no recrear el contexto WebGL al cambiar de una a otra. Medido: 100/100 presets de una muestra convierten, 84/84 de los que llevan shaders warp/comp; ~7 ms por preset. - Cada capa tiene su pestana arriba y su propia vista, y '+ CAPA' crea una y entra en ella: con varias capas, ir y volver a MAPPING no era viable. - Dos capas MilkDrop sin preset ya no salen identicas (cogian el indice 0): cada instancia elige uno al azar y lo escribe en el estado. - Una capa nueva ya no nace en la fuente "motor", que es el lienzo del motor activo y hacia que dos capas ensenaran lo mismo. Galeria de visuales - scripts/catalogar-visuales.py: ficha de cada clip (pelicula, personajes, duracion) y, sobre todo, si es una silueta de verdad y cuanta figura tiene. Distingue silueta de corte crudo por el negro puro del fondo: 0,63-0,90 frente a 0,04-0,06, sin zona gris. - El panel lista los clips agrupados por pelicula, con nombre legible y aviso de los que casi no tienen figura, en vez del nombre del archivo. - Soporte de siluetas con canal alfa (.webm VP9): transparencia de verdad, sin recorte por luminancia. El shader del mapper ya la respeta. Documentacion - docs/visuales.md nuevo; pendiente.md al dia con lo hecho y lo que queda. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d5223db9f
commit
f017246f0e
30 changed files with 3771 additions and 292 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
68
README.md
68
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:
|
|||
<img src="docs/assets/hydra.png" alt="Hydra" width="940">
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/assets/capas.png" alt="Tres capas con visuales distintos" width="900">
|
||||
</div>
|
||||
|
||||
| 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:
|
|||
<img src="docs/assets/panel.png" alt="Panel de control de FOSFENO" width="940">
|
||||
</div>
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
"hostname": "fosfeno",
|
||||
"_comment": "Nombre de red de la Raspberry. El panel queda en http://<hostname>.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,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
237
docs/arquitectura.md
Normal file
237
docs/arquitectura.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
BIN
docs/assets/capas.png
Normal file
BIN
docs/assets/capas.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
docs/assets/mapping.png
Normal file
BIN
docs/assets/mapping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
docs/assets/mezclador.png
Normal file
BIN
docs/assets/mezclador.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 135 KiB |
|
|
@ -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
|
||||
|
|
|
|||
203
docs/mapping.md
203
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.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/mapping.png" alt="La pestaña MAPPING del panel" width="720">
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/capas.png" alt="Tres capas con visuales distintos" width="820">
|
||||
</div>
|
||||
|
||||
### 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).
|
||||
|
|
|
|||
182
docs/mezclador.md
Normal file
182
docs/mezclador.md
Normal file
|
|
@ -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.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/mezclador.png" alt="La tarjeta del Mezclador VJ" width="480">
|
||||
</div>
|
||||
|
||||
## 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).
|
||||
162
docs/pendiente.md
Normal file
162
docs/pendiente.md
Normal file
|
|
@ -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.
|
||||
94
docs/seguridad.md
Normal file
94
docs/seguridad.md
Normal file
|
|
@ -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://<ip-de-fosfeno>/` **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.
|
||||
50
docs/uso.md
50
docs/uso.md
|
|
@ -18,6 +18,25 @@ conectado con la Raspberry. Rojo quiere decir que se ha perdido la conexión.
|
|||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
160
docs/visuales.md
Normal file
160
docs/visuales.md
Normal file
|
|
@ -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` → `<algo>_silueta-<modo>.mp4` → `tipo: silueta`
|
||||
- `recortar.py` → `<peli>_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 `<optgroup>`, 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.
|
||||
|
|
@ -261,6 +261,10 @@ find_copy() { # find_copy <paquete> <nombre> <destino> <descripcion>
|
|||
}
|
||||
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 \
|
||||
|
|
|
|||
390
scripts/catalogar-visuales.py
Executable file
390
scripts/catalogar-visuales.py
Executable file
|
|
@ -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 -> <algo>_silueta-<modo>.mp4 (ya recortado)
|
||||
# recortar.py -> <peli>_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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,25 @@
|
|||
<link rel="stylesheet" href="/lib/codemirror/material-darker.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Barra superior fija: titulo + pestanas + banda de avisos.
|
||||
Van juntas en un mismo bloque sticky para que el aviso quede siempre
|
||||
pegado debajo del titulo, aunque las pestanas salten de linea. -->
|
||||
<div class="topbar">
|
||||
<header>
|
||||
<h1>FOSFENO</h1>
|
||||
<!-- Dos zonas del panel: MOTORES (lo de siempre) y MAPPING (aparte,
|
||||
para que no se mezclen los controles de directo con los de
|
||||
encaje sobre la superficie fisica). -->
|
||||
<nav class="tabs" id="tabs">
|
||||
<button class="tab active" data-view="motores">MOTORES</button>
|
||||
<button class="tab" data-view="mapping">MAPPING<span
|
||||
id="tab-map-dot" class="tabdot" hidden></span></button>
|
||||
<!-- Una pestana por capa del mapping: se generan solas (renderTabsCapas)
|
||||
para poder saltar a editar cada capa sin pasar por MAPPING -->
|
||||
<span id="tabs-capas"></span>
|
||||
<button class="tab tabnew" id="tab-nueva-capa"
|
||||
title="Crear una capa nueva y abrirla">+ CAPA</button>
|
||||
</nav>
|
||||
<span id="conn" class="dot off" title="Conexion"></span>
|
||||
</header>
|
||||
|
||||
|
|
@ -19,6 +36,7 @@
|
|||
<span id="notif-msg"></span>
|
||||
<button id="notif-close" aria-label="Cerrar aviso">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- En movil las tarjetas van en una sola columna; en pantalla ancha,
|
||||
en dos columnas (.column pasa a display:contents en movil). -->
|
||||
|
|
@ -148,38 +166,69 @@
|
|||
<p class="hint" id="editor-hint"></p>
|
||||
</section>
|
||||
|
||||
<!-- Mezclador VJ -->
|
||||
<!-- Mezclador VJ.
|
||||
Se lee de arriba abajo como dos capas: el FONDO (capa de abajo:
|
||||
camara o visuales Butter) y el CLIP de la galeria (capa de
|
||||
encima), mas la forma de juntarlos. El resumen de mas abajo
|
||||
dice en una linea que va a salir por el proyector. -->
|
||||
<section class="card" id="ctl-mixer" hidden>
|
||||
<div class="cardhead">
|
||||
<span class="label">Mezclador VJ</span>
|
||||
<button class="info" data-help="mixer">i</button>
|
||||
</div>
|
||||
|
||||
<!-- 1. Que se ve -->
|
||||
<span class="label">1 · Que se ve</span>
|
||||
<div class="seg" id="mixer-source">
|
||||
<button class="segbtn" data-src="cam">Camara</button>
|
||||
<button class="segbtn" data-src="video">Video</button>
|
||||
<button class="segbtn" data-src="mix">Mezcla</button>
|
||||
<button class="segbtn" data-src="cam">Solo el fondo</button>
|
||||
<button class="segbtn" data-src="video">Solo el clip</button>
|
||||
<button class="segbtn" data-src="mix">Los dos</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Fondo (capa de abajo)</span>
|
||||
|
||||
<!-- 2. Fondo: capa de abajo -->
|
||||
<div class="row" id="mix-fondo-row">
|
||||
<span class="label">2 · Fondo (capa de abajo)</span>
|
||||
<select id="mix-fondo" class="inline">
|
||||
<option value="cam">Camara</option>
|
||||
<option value="butter">Visuales Butter (MilkDrop)</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Fondo = camara -->
|
||||
<div class="stack" id="mix-cam-block">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="mix-cam"> Camara activada
|
||||
</label>
|
||||
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
|
||||
</div>
|
||||
<!-- Fondo = visuales Butter: se elige aqui mismo el preset de
|
||||
MilkDrop que hace de fondo, sin salir del mezclador. -->
|
||||
<div class="stack" id="mix-bg-block" hidden>
|
||||
<select id="mix-bg-preset"><option>Cargando presets...</option></select>
|
||||
<div class="row">
|
||||
<button id="mix-bg-prev" class="cmd">« Anterior</button>
|
||||
<button id="mix-bg-next" class="cmd">Siguiente »</button>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="mix-bg-shuffle"> Cambiar el fondo solo
|
||||
</label>
|
||||
<p class="hint">Es el mismo preset y el mismo cambio automatico del
|
||||
motor Butter: lo que elijas aqui queda elegido tambien alli.</p>
|
||||
</div>
|
||||
|
||||
<!-- 3. Clip de la galeria: capa de encima -->
|
||||
<span class="label" id="mix-clip-label">3 · Clip (capa de encima)</span>
|
||||
<select id="mix-video"><option value="">-- sin video --</option></select>
|
||||
<div class="row">
|
||||
<button id="mix-rescan" class="cmd">Actualizar lista</button>
|
||||
<button id="mix-upload-btn" class="cmd">Subir video o imagen</button>
|
||||
<span id="mix-upload-status" class="hint"></span>
|
||||
</div>
|
||||
<span id="mix-upload-status" class="hint"></span>
|
||||
<input type="file" id="mix-upload" hidden
|
||||
accept="video/*,image/jpeg,image/png,image/gif,image/webp">
|
||||
<div class="row">
|
||||
<span class="label">Modo de mezcla</span>
|
||||
|
||||
<!-- 4. Como se juntan las dos capas -->
|
||||
<div class="row" id="mix-blend-row">
|
||||
<span class="label">4 · Como se juntan</span>
|
||||
<select id="mix-blend" class="inline">
|
||||
<option value="recorte">Recorte (personaje delante)</option>
|
||||
<option value="blend">Fundido</option>
|
||||
|
|
@ -189,6 +238,10 @@
|
|||
<option value="layer">Capa</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Resumen en una linea de lo que sale por el proyector -->
|
||||
<p class="hint resumen" id="mix-resumen"></p>
|
||||
|
||||
<div id="mixer-sliders"></div>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="mix-invert"> Invertir colores
|
||||
|
|
@ -248,37 +301,165 @@
|
|||
deslizador relanza projectM (~1 s en negro).</p>
|
||||
</section>
|
||||
|
||||
<!-- Mapping (projection mapping) -->
|
||||
<section class="card" id="card-mapping">
|
||||
<!-- ===================== PESTANA MAPPING ===================== -->
|
||||
|
||||
<!-- Salida: encendido y previsualizacion -->
|
||||
<section class="card" id="card-mapping" data-vista="mapping">
|
||||
<div class="cardhead">
|
||||
<span class="label">Mapping</span>
|
||||
<button class="info" data-help="mapping">i</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="map-enabled"> Activar mapping
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="map-edit"> Modo edicion (arrastrar en el escenario)
|
||||
<input type="checkbox" id="map-edit"> Editar en el escenario
|
||||
</label>
|
||||
</div>
|
||||
<span class="label">Previsualizacion (arrastra los puntos)</span>
|
||||
<canvas id="map-preview"></canvas>
|
||||
<p class="hint">Arrastra los puntos para encajar cada capa en su sitio.
|
||||
Toca dentro de una capa para seleccionarla. Se guarda solo.</p>
|
||||
</section>
|
||||
|
||||
<!-- Capas: la lista, al estilo de una mesa de VJ -->
|
||||
<section class="card" id="card-capas" data-vista="mapping">
|
||||
<div class="cardhead">
|
||||
<span class="label">Capas</span>
|
||||
<button class="info" data-help="capas">i</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="map-add" class="cmd accent">+ Superficie</button>
|
||||
<button id="map-add" class="cmd accent">+ Capa</button>
|
||||
<button id="map-add-mask" class="cmd">+ Mascara</button>
|
||||
<button id="map-reset" class="cmd">Reset</button>
|
||||
</div>
|
||||
<span class="label">Previsualizacion (arrastra los puntos)</span>
|
||||
<canvas id="map-preview"
|
||||
style="width:100%;aspect-ratio:16/9;background:#0a0a0a;border:1px solid #333;border-radius:6px;display:block;margin:6px 0;touch-action:none;cursor:crosshair;"></canvas>
|
||||
<div class="row" id="map-selbar" hidden>
|
||||
<span class="label" id="map-selinfo">Superficie seleccionada</span>
|
||||
<button id="map-sub-dec" class="cmd">− malla</button>
|
||||
<span id="map-sub-val" class="value">1x1</span>
|
||||
<button id="map-sub-inc" class="cmd">+ malla</button>
|
||||
</div>
|
||||
<p class="hint"><b>Capa</b> = una zona que PROYECTA algo (eliges que en
|
||||
Propiedades). <b>Mascara</b> = un recuadro negro que TAPA lo que haya
|
||||
debajo; no lleva visual.</p>
|
||||
<div id="map-list"></div>
|
||||
<p class="hint">Arrastra los puntos para deformar. Selecciona una
|
||||
superficie y usa <b>+ malla</b> para subdividir y <b>curvarla</b>.
|
||||
<b>+ Mascara</b> tapa zonas con un recuadro negro (recorta derrames
|
||||
de luz). Se guarda solo.</p>
|
||||
<p class="hint" id="map-list-vacio">Sin capas: con el mapping activado
|
||||
y ninguna capa, sale el motor a pantalla completa. Pulsa
|
||||
<b>+ Capa</b> para trocear la proyeccion en zonas.</p>
|
||||
</section>
|
||||
|
||||
<!-- Propiedades de la capa seleccionada -->
|
||||
<section class="card" id="card-propiedades" data-vista="mapping">
|
||||
<div class="cardhead">
|
||||
<span class="label" id="prop-titulo">Propiedades</span>
|
||||
<button class="info" data-help="fuentes">i</button>
|
||||
</div>
|
||||
|
||||
<p class="hint" id="prop-vacio">Selecciona una capa (en la lista o
|
||||
tocando dentro de ella en la previsualizacion) para elegir que
|
||||
visual se proyecta en esa zona.</p>
|
||||
|
||||
<div class="stack" id="prop-capa" hidden>
|
||||
<div class="row">
|
||||
<span class="label">Nombre</span>
|
||||
<input type="text" id="prop-nombre" class="txt" maxlength="24"
|
||||
placeholder="Capa">
|
||||
</div>
|
||||
|
||||
<span class="label">Que se proyecta aqui</span>
|
||||
<select id="prop-fuente">
|
||||
<option value="motor">El motor elegido en MOTORES</option>
|
||||
<option value="butter">Visuales MilkDrop (propias de esta capa)</option>
|
||||
<option value="shader">Shader GLSL</option>
|
||||
<option value="video">Clip o imagen de la galeria</option>
|
||||
<option value="camara">Camara</option>
|
||||
<option value="negro">Negro (tapar)</option>
|
||||
</select>
|
||||
|
||||
<!-- Ajustes propios de cada fuente -->
|
||||
<div class="stack fuente-op" id="fop-butter" hidden>
|
||||
<span class="label">Biblioteca de presets</span>
|
||||
<select id="prop-biblioteca">
|
||||
<option value="butter">Butterchurn — ~100 presets, los mas ligeros</option>
|
||||
<option value="projectm">projectM — tu biblioteca .milk (9795)</option>
|
||||
</select>
|
||||
|
||||
<!-- Biblioteca de Butterchurn -->
|
||||
<div class="stack" id="fop-bib-butter">
|
||||
<select id="prop-preset"><option>Cargando presets...</option></select>
|
||||
<div class="row">
|
||||
<button id="prop-preset-prev" class="cmd">« Anterior</button>
|
||||
<button id="prop-preset-next" class="cmd">Siguiente »</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Biblioteca de projectM: los .milk se traducen al vuelo -->
|
||||
<div class="stack" id="fop-bib-milk" hidden>
|
||||
<select id="prop-milk-cat"><option value="">-- categoria --</option></select>
|
||||
<select id="prop-milk-vis"><option value="">-- visual --</option></select>
|
||||
<select id="prop-milk-var"><option value="">-- variante --</option></select>
|
||||
<button id="prop-milk-azar" class="cmd">Uno al azar</button>
|
||||
<p class="hint" id="prop-milk-est">Los .milk se traducen a
|
||||
Butterchurn en el propio navegador, al elegirlos. Alguno puede
|
||||
salir distinto al original: si uno no convence, prueba otra
|
||||
variante.</p>
|
||||
</div>
|
||||
|
||||
<label class="check">
|
||||
<input type="checkbox" id="prop-auto"> Cambiar de preset solo
|
||||
</label>
|
||||
<div class="row" id="prop-auto-row" hidden>
|
||||
<span class="label">Cada <span id="prop-seg-val" class="value">20</span> s</span>
|
||||
</div>
|
||||
<input id="prop-seg" type="range" min="3" max="180" step="1" value="20" hidden>
|
||||
<div class="row">
|
||||
<span class="label">Transicion:
|
||||
<span id="prop-tr-val" class="value">2.5</span> s</span>
|
||||
</div>
|
||||
<input id="prop-transicion" type="range" min="0" max="8" step="0.1" value="2.5">
|
||||
<p class="hint">A 0 el preset cambia de golpe; subiendolo, uno se
|
||||
disuelve en el siguiente sin que se note el corte.</p>
|
||||
</div>
|
||||
|
||||
<div class="stack fuente-op" id="fop-shader" hidden>
|
||||
<select id="prop-shader"><option value="">-- elige un shader --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="stack fuente-op" id="fop-video" hidden>
|
||||
<select id="prop-video"><option value="">-- elige un clip --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="stack fuente-op" id="fop-camara" hidden>
|
||||
<select id="prop-camara"><option value="0">Camara por defecto</option></select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">Opacidad: <span id="prop-op-val" class="value">100</span>%</span>
|
||||
</div>
|
||||
<input id="prop-opacidad" type="range" min="0" max="1" step="0.02" value="1">
|
||||
|
||||
<div class="row">
|
||||
<span class="label">Mezcla con lo de debajo</span>
|
||||
<select id="prop-mezcla" class="inline">
|
||||
<option value="normal">Normal (tapa)</option>
|
||||
<option value="sumar">Sumar (apila luz)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">Malla (para curvar)</span>
|
||||
<span class="grupo">
|
||||
<button id="map-sub-dec" class="cmd">−</button>
|
||||
<span id="map-sub-val" class="value">1x1</span>
|
||||
<button id="map-sub-inc" class="cmd">+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack" id="prop-mascara" hidden>
|
||||
<p class="hint">Una mascara solo TAPA: es un recuadro negro que se
|
||||
pinta encima de todo, para recortar derrames de luz. No lleva
|
||||
visual, por eso aqui no hay nada que elegir. Arrastra sus esquinas
|
||||
en la previsualizacion.</p>
|
||||
<p class="hint resumen">Si lo que querias era proyectar algo en esta
|
||||
zona, eso es una <b>capa</b>, no una mascara.</p>
|
||||
<button id="mask-a-capa" class="cmd accent">Convertirla en capa</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -70,7 +70,11 @@
|
|||
<script src="/lib/qrcode.js"></script>
|
||||
<script src="/lib/butterchurn.min.js"></script>
|
||||
<script src="/lib/butterchurn-presets.min.js"></script>
|
||||
<!-- Traduce los .milk de projectM a presets de Butterchurn, para poder
|
||||
usar la biblioteca grande dentro de una capa del mapping -->
|
||||
<script src="/lib/milkdrop-preset-converter.min.js"></script>
|
||||
<script src="/lib/hydra-synth.js"></script>
|
||||
<script src="/stage/layers.js"></script>
|
||||
<script src="/stage/mapper.js"></script>
|
||||
<script src="/stage/stage.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
457
web/stage/layers.js
Normal file
457
web/stage/layers.js
Normal file
|
|
@ -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 <video> o de una <img> dispara su
|
||||
evento 'error', y sin la bandera saldria un aviso falso de "no pudo
|
||||
cargar" cada vez que cambias de clip o quitas la capa. */
|
||||
function crearVideo(f) {
|
||||
if (!f.archivo) return null;
|
||||
const url = '/data/videos/' + encodeURIComponent(f.archivo);
|
||||
let soltado = false;
|
||||
if (IMAGEN_RE.test(f.archivo)) {
|
||||
const img = new Image();
|
||||
img.onerror = () => {
|
||||
if (!soltado) aviso('warn', "La capa no pudo cargar la imagen '" + f.archivo + "'.");
|
||||
};
|
||||
img.src = url;
|
||||
return { el: img, soltar() { soltado = true; img.src = ''; } };
|
||||
}
|
||||
const vid = document.createElement('video');
|
||||
vid.muted = true; vid.loop = true; vid.playsInline = true;
|
||||
vid.addEventListener('error', () => {
|
||||
if (soltado) return;
|
||||
aviso('warn', "La capa no pudo cargar '" + f.archivo
|
||||
+ "': prueba con .mp4 (H.264) o .webm.");
|
||||
});
|
||||
vid.addEventListener('loadeddata', () => vid.play().catch(() => {}));
|
||||
vid.src = url;
|
||||
return {
|
||||
el: vid,
|
||||
soltar() {
|
||||
soltado = true;
|
||||
vid.pause();
|
||||
vid.removeAttribute('src'); // mas limpio que src="" para soltarlo
|
||||
vid.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function crearCamara(f) {
|
||||
const vid = document.createElement('video');
|
||||
vid.muted = true; vid.playsInline = true;
|
||||
let stream = null, muerto = false;
|
||||
navigator.mediaDevices.enumerateDevices().then((devs) => {
|
||||
const cams = devs.filter((d) => d.kind === 'videoinput');
|
||||
if (!cams.length) throw new Error('no hay ninguna camara conectada');
|
||||
const pick = cams[f.camaraId] || cams[0];
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
audio: false, video: { deviceId: { exact: pick.deviceId } },
|
||||
});
|
||||
}).then((st) => {
|
||||
// La capa pudo borrarse o cambiar de camara mientras esta tardaba en
|
||||
// abrirse (1-3 s por USB): si no se corta aqui, el piloto se queda
|
||||
// encendido hasta recargar el escenario.
|
||||
if (muerto) { st.getTracks().forEach((t) => t.stop()); return null; }
|
||||
stream = st; vid.srcObject = st;
|
||||
return vid.play();
|
||||
}).catch((e) => {
|
||||
if (!muerto) aviso('error', 'La capa de camara fallo: ' + e.message);
|
||||
});
|
||||
return {
|
||||
el: vid,
|
||||
soltar() {
|
||||
muerto = true;
|
||||
if (stream) stream.getTracks().forEach((t) => t.stop());
|
||||
vid.srcObject = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ ciclo de vida
|
||||
function crear(f, nombre) {
|
||||
if (f.tipo === 'butter') return crearButter(f);
|
||||
if (f.tipo === 'shader') return crearShaderCapa(f, nombre);
|
||||
if (f.tipo === 'video') {
|
||||
if (!f.archivo) {
|
||||
aviso('warn', 'La capa "' + nombre + '" no tiene clip elegido: se ve '
|
||||
+ 'negra hasta que selecciones uno en Propiedades.');
|
||||
return null;
|
||||
}
|
||||
const r = recursoCompartido('video|' + f.archivo, () => crearVideo(f));
|
||||
return r ? { el: r.el, compartida: 'video|' + f.archivo, aplicar() {}, destruir() {} } : null;
|
||||
}
|
||||
if (f.tipo === 'camara') {
|
||||
const r = recursoCompartido('camara|' + f.camaraId, () => crearCamara(f));
|
||||
return r ? { el: r.el, compartida: 'camara|' + f.camaraId, aplicar() {}, destruir() {} } : null;
|
||||
}
|
||||
// "motor" y "negro" no necesitan instancia; cualquier otro tipo es de una
|
||||
// version distinta y hay que decirlo en vez de dejar la zona en negro.
|
||||
if (f.tipo !== 'motor' && f.tipo !== 'negro') {
|
||||
aviso('warn', 'La capa "' + nombre + '" usa una fuente desconocida ("'
|
||||
+ f.tipo + '"): elige otra en Propiedades.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function soltarCompartida(clave) {
|
||||
const r = compartidas.get(clave);
|
||||
if (!r) return;
|
||||
r.usos--;
|
||||
if (r.usos <= 0) { try { r.soltar(); } catch (e) { /* ya suelto */ } compartidas.delete(clave); }
|
||||
}
|
||||
|
||||
function destruirCapa(id) {
|
||||
const c = capas.get(id);
|
||||
if (!c) return;
|
||||
if (c.inst) {
|
||||
try { c.inst.destruir(); } catch (e) { /* ya destruida */ }
|
||||
if (c.inst.compartida) soltarCompartida(c.inst.compartida);
|
||||
}
|
||||
capas.delete(id);
|
||||
}
|
||||
|
||||
const API = {
|
||||
init(opciones) { deps = opciones || {}; },
|
||||
|
||||
setMotor(canvas) { motorEl = canvas || null; },
|
||||
|
||||
/* Pone las capas al dia con las superficies del estado: crea las nuevas,
|
||||
actualiza las que solo han cambiado de ajuste y tira las que sobran. */
|
||||
sync(surfaces) {
|
||||
if (!deps) return;
|
||||
const vivos = new Set();
|
||||
(surfaces || []).forEach((s, i) => {
|
||||
if (!s || !s.id) return;
|
||||
vivos.add(s.id);
|
||||
const f = fuenteDe(s);
|
||||
// Una capa apagada o a cero no gasta nada: ni motores renderizando ni
|
||||
// la camara encendida por algo que no se ve. Mismo criterio que usa
|
||||
// el mapper para no pintarla.
|
||||
const oculta = s.visible === false
|
||||
|| (typeof s.opacidad === 'number' && s.opacidad <= 0);
|
||||
const sig = oculta ? 'oculta' : firma(f);
|
||||
const c = capas.get(s.id);
|
||||
if (c && c.sig === sig) {
|
||||
if (c.inst && c.inst.aplicar) c.inst.aplicar(f);
|
||||
return;
|
||||
}
|
||||
if (c) destruirCapa(s.id);
|
||||
if (oculta || f.tipo === 'motor' || f.tipo === 'negro') {
|
||||
capas.set(s.id, { sig: sig, inst: null });
|
||||
return;
|
||||
}
|
||||
const inst = crear(f, s.nombre || 'Capa ' + (i + 1));
|
||||
capas.set(s.id, { sig: sig, inst: inst });
|
||||
// Si la capa tuvo que elegir preset por su cuenta, que el estado se
|
||||
// entere: asi el desplegable de Propiedades deja de salir en blanco.
|
||||
if (inst && inst.presetElegido && deps.onPreset)
|
||||
deps.onPreset(s.id, inst.presetElegido);
|
||||
});
|
||||
Array.from(capas.keys()).forEach((id) => { if (!vivos.has(id)) destruirCapa(id); });
|
||||
},
|
||||
|
||||
/* Que elemento tiene que usar el mapper como textura de esta superficie.
|
||||
La clave sirve para no subir la misma imagen dos veces por fotograma. */
|
||||
fuenteDe(s) {
|
||||
const f = fuenteDe(s);
|
||||
if (f.tipo === 'negro') return null;
|
||||
if (f.tipo === 'motor') return motorEl ? { el: motorEl, clave: 'motor' } : null;
|
||||
const c = capas.get(s.id);
|
||||
if (!c || !c.inst || !c.inst.el) return null;
|
||||
return { el: c.inst.el, clave: c.inst.compartida || ('capa:' + s.id) };
|
||||
},
|
||||
|
||||
presets() {
|
||||
if (!window.butterchurnPresets) return [];
|
||||
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
|
||||
return Object.keys(bp.getPresets());
|
||||
},
|
||||
};
|
||||
|
||||
window.FosLayers = API;
|
||||
})();
|
||||
|
|
@ -1,26 +1,34 @@
|
|||
/*
|
||||
* FOSFENO :: Mapper (projection mapping en el navegador) — Fase 2
|
||||
* FOSFENO :: Mapper (projection mapping en el navegador) — Fase 3
|
||||
* --------------------------------------------------------------------------
|
||||
* Coge el canvas del motor activo como TEXTURA y lo pinta deformado sobre
|
||||
* SUPERFICIES con MALLA (rejilla n x n deformable => superficies curvas, no
|
||||
* solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que
|
||||
* Pinta CAPAS deformadas sobre superficies con MALLA (rejilla n x n => curvas,
|
||||
* no solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que
|
||||
* tapan zonas / recortan derrames de luz).
|
||||
*
|
||||
* Cada superficie es una CAPA con su propia fuente: el motor activo, una
|
||||
* instancia propia de MilkDrop, un shader, un clip o la camara (las crea
|
||||
* layers.js). Asi se puede tener MilkDrop a la derecha, otro preset a la
|
||||
* izquierda y un video en el centro, cada uno encajado en su sitio.
|
||||
*
|
||||
* Modelo (0..1, origen arriba-izquierda; se guarda en data/mapping.json):
|
||||
* surface = { id, n, points:[[x,y] x (n+1)^2] } // rejilla n x n celdas
|
||||
* surface = { id, n, points:[[x,y] x (n+1)^2],
|
||||
* nombre, visible, opacidad, mezcla:"normal"|"sumar",
|
||||
* fuente:{ tipo, ... } } // ver layers.js
|
||||
* mask = { id, corners:[[x,y] x4] } // TL,TR,BR,BL, negro
|
||||
* mapping = { enabled, edit, surfaces:[...], masks:[...] }
|
||||
*
|
||||
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1.
|
||||
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1, y
|
||||
* las que no llevan fuente se pintan con el motor activo, como siempre.
|
||||
*
|
||||
* Render: cada celda de la malla se dibuja como un parche con homografia
|
||||
* (cuadrado unidad -> celda) sobre una sub-malla fina => perspectiva correcta.
|
||||
*
|
||||
* API global:
|
||||
* FosMapper.init({ onChange }) arranca
|
||||
* FosMapper.setSource(canvas) motor activo (o null)
|
||||
* FosMapper.init({ onChange, resolver }) arranca
|
||||
* FosMapper.setSource(canvas) lienzo del motor activo (fuente "motor")
|
||||
* FosMapper.setMapping(m) estado de mapping
|
||||
* onChange({surfaces, masks}) al editar (arrastrar), para guardar
|
||||
* resolver(surface) -> { el, clave } con la textura de la capa
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
|
@ -32,11 +40,15 @@
|
|||
const M = { enabled: false, edit: false, surfaces: [], masks: [] };
|
||||
let source = null;
|
||||
let onChange = null;
|
||||
let resolver = null; // surface -> { el, clave } (lo pone layers.js)
|
||||
let aviso = function () {}; // report() del escenario: nada se queda callado
|
||||
|
||||
let outputEl, editEl, gl, ectx;
|
||||
let prog, aUV, uH, uTex, uOff, uScale; // programa de textura
|
||||
let prog, aUV, uH, uTex, uOff, uScale, uAlpha; // programa de textura
|
||||
let progF, aPos, uColor, fillBuf; // programa de relleno (mascaras)
|
||||
let tex, gridBuf, gridIdx, gridCount, dpr = 1;
|
||||
let gridBuf, gridIdx, gridCount, dpr = 1;
|
||||
const texturas = new Map(); // clave de fuente -> { tex, subida, visto }
|
||||
let frame = 0;
|
||||
|
||||
let selected = null; // { type:'surface'|'mask', id }
|
||||
let drag = null; // { type, id, pt|null, sx, sy, orig }
|
||||
|
|
@ -46,16 +58,39 @@
|
|||
const sidx = (c, r, n) => r * (n + 1) + c;
|
||||
|
||||
// ------------------------------------------------------------------ modelo
|
||||
/* Normaliza una superficie: geometria a formato malla y ajustes de capa con
|
||||
sus valores por defecto. Lo que no venga (superficies de antes de las
|
||||
capas) se rellena para que el resto del codigo no tenga que preguntar. */
|
||||
function migrate(s) {
|
||||
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: s.fuente || { tipo: 'motor' },
|
||||
};
|
||||
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)) { // formato Fase 1 -> n=1
|
||||
capa.n = s.n; capa.points = s.points.map((p) => [p[0], p[1]]);
|
||||
} else if (Array.isArray(s.corners)) { // formato Fase 1 -> n=1
|
||||
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 (row-major)
|
||||
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 (row-major)
|
||||
} 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;
|
||||
}
|
||||
|
||||
/* Mascaras saneadas. Una sin esquinas (mapping.json a medio escribir, de
|
||||
otra version o tocado a mano) no puede tumbar el escenario entero: se
|
||||
descarta y punto. */
|
||||
function migrateMasks(lista) {
|
||||
return (Array.isArray(lista) ? lista : [])
|
||||
.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]]) }));
|
||||
}
|
||||
|
||||
// punto [x,y] a coordenada normalizada (u,v) de la superficie (bilineal por celda)
|
||||
|
|
@ -74,7 +109,11 @@
|
|||
const pts = [];
|
||||
for (let r = 0; r <= newN; r++)
|
||||
for (let c = 0; c <= newN; c++) pts.push(sampleSurf(s, c / newN, r / newN));
|
||||
return { id: s.id, n: newN, points: pts };
|
||||
// Solo cambia la malla: los ajustes de la capa (fuente, nombre, opacidad)
|
||||
// se conservan tal cual.
|
||||
const out = migrate(s);
|
||||
out.n = newN; out.points = pts;
|
||||
return out;
|
||||
}
|
||||
|
||||
// homografia cuadrado unidad -> cuadrilatero (Heckbert), mat3 por columnas
|
||||
|
|
@ -117,7 +156,11 @@
|
|||
function initGL() {
|
||||
gl = outputEl.getContext('webgl', { alpha: false, antialias: true })
|
||||
|| outputEl.getContext('experimental-webgl');
|
||||
if (!gl) { console.error('[FOSFENO] mapper: sin WebGL'); return false; }
|
||||
if (!gl) {
|
||||
aviso('error', 'El mapping necesita WebGL y este navegador no lo tiene: '
|
||||
+ 'las visuales saldran sin deformar.');
|
||||
return false;
|
||||
}
|
||||
|
||||
prog = linkProg(
|
||||
`attribute vec2 aUV; uniform mat3 uH; uniform vec2 uOff; uniform vec2 uScale;
|
||||
|
|
@ -126,12 +169,15 @@
|
|||
gl_Position = vec4(d.x*2.0-1.0, 1.0-d.y*2.0, 0.0, 1.0);
|
||||
vUV = uOff + aUV*uScale; }`,
|
||||
`precision mediump float; varying vec2 vUV; uniform sampler2D uTex;
|
||||
void main(){ gl_FragColor = texture2D(uTex, vUV); }`);
|
||||
uniform float uAlpha;
|
||||
void main(){ vec4 c = texture2D(uTex, vUV);
|
||||
gl_FragColor = vec4(c.rgb, c.a * uAlpha); }`);
|
||||
aUV = gl.getAttribLocation(prog, 'aUV');
|
||||
uH = gl.getUniformLocation(prog, 'uH');
|
||||
uTex = gl.getUniformLocation(prog, 'uTex');
|
||||
uOff = gl.getUniformLocation(prog, 'uOff');
|
||||
uScale = gl.getUniformLocation(prog, 'uScale');
|
||||
uAlpha = gl.getUniformLocation(prog, 'uAlpha');
|
||||
|
||||
progF = linkProg(
|
||||
`attribute vec2 aPos; void main(){ gl_Position = vec4(aPos,0.0,1.0); }`,
|
||||
|
|
@ -158,14 +204,51 @@
|
|||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW);
|
||||
|
||||
tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
/* SIN voltear la textura al subirla. El modelo 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. Poniendo
|
||||
UNPACK_FLIP_Y a true, t = 0 pasaba a ser la ultima fila y todo salia
|
||||
del reves — se notaba sobre todo con los clips de video. */
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
gl.enable(gl.BLEND);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Una textura por fuente, subida una sola vez por fotograma: si dos
|
||||
superficies comparten el mismo clip, la imagen no viaja dos veces. */
|
||||
function texturaDe(src) {
|
||||
const el = src.el;
|
||||
if (!el) return null;
|
||||
const w = el.videoWidth || el.naturalWidth || el.width;
|
||||
const h = el.videoHeight || el.naturalHeight || el.height;
|
||||
if (!w || !h) return null;
|
||||
if (el.tagName === 'VIDEO' && el.readyState < 2) return null;
|
||||
let e = texturas.get(src.clave);
|
||||
if (!e) {
|
||||
e = { tex: gl.createTexture(), subida: -1, visto: -1 };
|
||||
gl.bindTexture(gl.TEXTURE_2D, e.tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
return true;
|
||||
texturas.set(src.clave, e);
|
||||
}
|
||||
e.visto = frame;
|
||||
if (e.subida !== frame) {
|
||||
e.subida = frame;
|
||||
gl.bindTexture(gl.TEXTURE_2D, e.tex);
|
||||
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el); }
|
||||
catch (err) { return null; }
|
||||
}
|
||||
return e.tex;
|
||||
}
|
||||
|
||||
/* Tira las texturas de fuentes que ya no se usan (capas borradas). */
|
||||
function limpiarTexturas() {
|
||||
if (frame % 300) return;
|
||||
texturas.forEach((e, clave) => {
|
||||
if (frame - e.visto > 120) { gl.deleteTexture(e.tex); texturas.delete(clave); }
|
||||
});
|
||||
}
|
||||
|
||||
function drawSurfaceGL(s) {
|
||||
|
|
@ -182,32 +265,63 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Pantalla entera: lo que se ve cuando el mapping esta activo pero aun no
|
||||
// hay ninguna superficie creada (pasada directa del motor).
|
||||
const PANTALLA = { id: '__pantalla__', n: 1, visible: true, opacidad: 1,
|
||||
mezcla: 'normal', fuente: { tipo: 'motor' },
|
||||
points: [[0, 0], [1, 0], [0, 1], [1, 1]] };
|
||||
|
||||
// La fuente de una capa: la resuelve layers.js; si no hay resolver (o la
|
||||
// capa es del motor), tira del lienzo del motor activo.
|
||||
function fuenteDeCapa(capa) {
|
||||
if (resolver) {
|
||||
const r = resolver(capa);
|
||||
if (r && r.el) return r;
|
||||
if (capa.fuente && capa.fuente.tipo !== 'motor') return null;
|
||||
}
|
||||
return (source && source.width) ? { el: source, clave: 'motor' } : null;
|
||||
}
|
||||
|
||||
function renderGL() {
|
||||
frame++;
|
||||
gl.viewport(0, 0, outputEl.width, outputEl.height);
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
if (!source || !source.width) return;
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); }
|
||||
catch (e) { return; }
|
||||
|
||||
// superficies
|
||||
// superficies (cada una con su propia fuente)
|
||||
gl.useProgram(prog);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
|
||||
gl.enableVertexAttribArray(aUV);
|
||||
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.uniform1i(uTex, 0);
|
||||
if (!M.surfaces.length) {
|
||||
drawSurfaceGL({ n: 1, points: [[0, 0], [1, 0], [0, 1], [1, 1]] }); // pasada directa
|
||||
} else {
|
||||
for (const s of M.surfaces) drawSurfaceGL(migrate(s));
|
||||
}
|
||||
|
||||
// mascaras (cuadrilateros negros encima)
|
||||
// M.surfaces ya viene normalizado de setMapping / onDown / onDblClick:
|
||||
// no hace falta migrar otra vez en cada fotograma (eran cientos de
|
||||
// arrays nuevos por segundo, justo en la maquina que menos lo aguanta).
|
||||
const lista = M.surfaces.length ? M.surfaces : [PANTALLA];
|
||||
for (const capa of lista) {
|
||||
if (!capa.visible || capa.opacidad <= 0) continue;
|
||||
if (capa.fuente.tipo === 'negro') continue;
|
||||
const src = fuenteDeCapa(capa);
|
||||
if (!src) continue;
|
||||
const t = texturaDe(src);
|
||||
if (!t) continue;
|
||||
gl.bindTexture(gl.TEXTURE_2D, t);
|
||||
gl.uniform1f(uAlpha, capa.opacidad);
|
||||
// "sumar" apila luz (las zonas oscuras dejan ver la capa de abajo);
|
||||
// "normal" tapa lo que haya debajo.
|
||||
if (capa.mezcla === 'sumar') gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
|
||||
else gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
drawSurfaceGL(capa);
|
||||
}
|
||||
limpiarTexturas();
|
||||
|
||||
// mascaras (cuadrilateros negros encima). Vuelve a mezcla normal: si la
|
||||
// ultima capa iba en "sumar", un negro sumado no taparia nada.
|
||||
if (M.masks.length) {
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.useProgram(progF);
|
||||
gl.uniform4f(uColor, 0, 0, 0, 1);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, fillBuf);
|
||||
|
|
@ -225,6 +339,28 @@
|
|||
}
|
||||
|
||||
// ------------------------------------------------------------------ editor
|
||||
const NOMBRE_FUENTE = {
|
||||
motor: 'motor activo', butter: 'MilkDrop', shader: 'shader',
|
||||
video: 'clip', camara: 'camara', negro: 'negro',
|
||||
};
|
||||
function etiquetaFuente(f) {
|
||||
f = f || {};
|
||||
const base = NOMBRE_FUENTE[f.tipo] || 'motor activo';
|
||||
if (f.tipo === 'butter' && f.preset) return base + ' · ' + f.preset;
|
||||
if (f.tipo === 'video' && f.archivo) return base + ' · ' + f.archivo;
|
||||
return base;
|
||||
}
|
||||
|
||||
/* Rotulo centrado que no se sale de la pantalla: una capa pegada al borde
|
||||
tendria el nombre cortado por la mitad. */
|
||||
function rotulo(txt, x, y, w) {
|
||||
let t = txt;
|
||||
while (t.length > 6 && ectx.measureText(t).width > w - 24) t = t.slice(0, -2);
|
||||
if (t !== txt) t = t.slice(0, -1) + '…';
|
||||
const media = ectx.measureText(t).width / 2;
|
||||
ectx.fillText(t, Math.min(Math.max(x, media + 10), w - media - 10), y);
|
||||
}
|
||||
|
||||
function drawEditor() {
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
ectx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
|
@ -232,9 +368,9 @@
|
|||
ectx.font = '600 14px system-ui, sans-serif';
|
||||
ectx.textAlign = 'center'; ectx.textBaseline = 'middle';
|
||||
|
||||
// superficies (malla)
|
||||
M.surfaces.forEach((raw, i) => {
|
||||
const s = migrate(raw); const n = s.n;
|
||||
// superficies (malla). Ya vienen normalizadas de setMapping.
|
||||
M.surfaces.forEach((s, i) => {
|
||||
const n = s.n;
|
||||
const sel = selected && selected.type === 'surface' && selected.id === s.id;
|
||||
const col = sel ? '#00e5ff' : '#b4ff00';
|
||||
const P = (c, r) => { const p = s.points[sidx(c, r, n)]; return [p[0] * w, p[1] * h]; };
|
||||
|
|
@ -242,9 +378,16 @@
|
|||
ectx.strokeStyle = col; ectx.lineWidth = sel ? 2 : 1.3;
|
||||
for (let r = 0; r <= n; r++) { ectx.beginPath(); for (let c = 0; c <= n; c++) { const p = P(c, r); c ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
|
||||
for (let c = 0; c <= n; c++) { ectx.beginPath(); for (let r = 0; r <= n; r++) { const p = P(c, r); r ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
|
||||
// numero
|
||||
const cc = P(n / 2 | 0, n / 2 | 0);
|
||||
ectx.fillStyle = col; ectx.fillText(String(i + 1), cc[0], cc[1]);
|
||||
// nombre de la capa y su fuente, en el centro de verdad: el medio de
|
||||
// las cuatro esquinas (con n=1, P(n/2|0, n/2|0) caia en la esquina).
|
||||
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];
|
||||
ectx.fillStyle = col;
|
||||
rotulo(s.nombre || ('Capa ' + (i + 1)), cc[0], cc[1] - 9, w);
|
||||
ectx.font = '12px system-ui, sans-serif';
|
||||
rotulo(etiquetaFuente(s.fuente), cc[0], cc[1] + 10, w);
|
||||
ectx.font = '600 14px system-ui, sans-serif';
|
||||
// tiradores (todos los puntos)
|
||||
for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) {
|
||||
const p = P(c, r);
|
||||
|
|
@ -364,7 +507,9 @@
|
|||
function onUp() { if (drag && dragged) emitNow(); drag = null; }
|
||||
|
||||
function defaultSurface(cx, cy, s) {
|
||||
return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4), n: 1,
|
||||
return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4),
|
||||
n: 1, nombre: '', visible: true, opacidad: 1, mezcla: 'normal',
|
||||
fuente: { tipo: 'motor' },
|
||||
points: [[clamp01(cx - s), clamp01(cy - s)], [clamp01(cx + s), clamp01(cy - s)],
|
||||
[clamp01(cx - s), clamp01(cy + s)], [clamp01(cx + s), clamp01(cy + s)]] };
|
||||
}
|
||||
|
|
@ -394,7 +539,7 @@
|
|||
function emitNow() {
|
||||
if (emitTimer) { clearTimeout(emitTimer); emitTimer = null; }
|
||||
if (onChange) onChange({
|
||||
surfaces: M.surfaces.map((s) => { const m = migrate(s); return { id: m.id, n: m.n, points: m.points }; }),
|
||||
surfaces: M.surfaces.map(migrate), // geometria + ajustes de la capa
|
||||
masks: M.masks.map((m) => ({ id: m.id, corners: m.corners })),
|
||||
});
|
||||
}
|
||||
|
|
@ -422,10 +567,24 @@
|
|||
init(opts) {
|
||||
opts = opts || {};
|
||||
onChange = opts.onChange || null;
|
||||
resolver = opts.resolver || null;
|
||||
aviso = opts.report || function () {};
|
||||
outputEl = document.getElementById('output');
|
||||
editEl = document.getElementById('mapedit');
|
||||
if (!outputEl || !editEl) { console.error('[FOSFENO] mapper: faltan #output/#mapedit'); return; }
|
||||
if (!outputEl || !editEl) {
|
||||
aviso('error', 'Falta el lienzo del mapping en la pagina del '
|
||||
+ 'escenario: recarga el escenario.');
|
||||
return;
|
||||
}
|
||||
ectx = editEl.getContext('2d');
|
||||
// Si el navegador tira este contexto (demasiadas capas a la vez), el
|
||||
// mapping se queda negro en silencio: que al menos se sepa por que.
|
||||
outputEl.addEventListener('webglcontextlost', (e) => {
|
||||
e.preventDefault();
|
||||
aviso('error', 'El navegador ha cerrado el contexto grafico del '
|
||||
+ 'mapping (demasiadas capas a la vez). Quita alguna capa de '
|
||||
+ 'MilkDrop o de shader y recarga el escenario.');
|
||||
});
|
||||
if (!initGL()) return;
|
||||
editEl.addEventListener('mousedown', onDown);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
|
|
@ -440,8 +599,10 @@
|
|||
M.enabled = !!m.enabled;
|
||||
M.edit = !!m.edit;
|
||||
if (!drag) { // no piso la geometria mientras arrastro aqui
|
||||
if (Array.isArray(m.surfaces)) M.surfaces = m.surfaces.map(migrate);
|
||||
M.masks = Array.isArray(m.masks) ? m.masks.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) })) : [];
|
||||
if (Array.isArray(m.surfaces)) {
|
||||
M.surfaces = m.surfaces.filter((s) => s && s.id).map(migrate);
|
||||
}
|
||||
M.masks = migrateMasks(m.masks);
|
||||
if (selected) {
|
||||
const ok = selected.type === 'surface' ? findSurf(selected.id) >= 0 : findMask(selected.id) >= 0;
|
||||
if (!ok) selected = null;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@
|
|||
* Incluye deteccion de BPM en vivo y seleccion de tarjeta de audio.
|
||||
*/
|
||||
|
||||
const socket = io();
|
||||
// Clave compartida (config.json -> auth.token). Viene en la propia direccion
|
||||
// del escenario; si no hay clave configurada, queda vacia y todo va como
|
||||
// siempre. Se reenvia en el QR para que el movil entre de un escaneo.
|
||||
const CLAVE = new URLSearchParams(location.search).get("k") || "";
|
||||
const socket = io({ auth: { token: CLAVE } });
|
||||
const msgEl = document.getElementById("msg");
|
||||
const bcCanvas = document.getElementById("butterchurn");
|
||||
const hydraCanvas = document.getElementById("hydra");
|
||||
|
|
@ -120,8 +124,12 @@ let beatDetector = null;
|
|||
// u_bpm, u_beat (fase 0..1), u_fft (sampler2D)
|
||||
// ==========================================================================
|
||||
class ShaderEngine {
|
||||
constructor(canvas, analyserNode) {
|
||||
/* size: {width, height} fija el lienzo (capas del mapping, que se
|
||||
renderizan pequenas y las estira el mapper). Sin size, el shader ocupa
|
||||
la pantalla entera, que es como va el motor de shaders normal. */
|
||||
constructor(canvas, analyserNode, size) {
|
||||
this.canvas = canvas;
|
||||
this.size = size || null;
|
||||
this.analyser = analyserNode;
|
||||
this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
||||
this.raf = null;
|
||||
|
|
@ -220,8 +228,8 @@ class ShaderEngine {
|
|||
|
||||
_frame() {
|
||||
const gl = this.gl;
|
||||
const w = this.canvas.width = window.innerWidth;
|
||||
const h = this.canvas.height = window.innerHeight;
|
||||
const w = this.canvas.width = this.size ? this.size.width : window.innerWidth;
|
||||
const h = this.canvas.height = this.size ? this.size.height : window.innerHeight;
|
||||
gl.viewport(0, 0, w, h);
|
||||
if (this.program) {
|
||||
const b = this._bands();
|
||||
|
|
@ -366,8 +374,9 @@ function beatLoop() {
|
|||
const isBeat = beatDetector ? beatDetector.update(now) : false;
|
||||
try { window.bpm = fosBeat.bpm || 30; } catch (e) { /* hydra no cargado */ }
|
||||
|
||||
// Cambio de preset de Butterchurn sincronizado al compas
|
||||
if (isBeat && state && state.engine === "butterchurn" && state.power
|
||||
// Cambio de preset de Butterchurn sincronizado al compas (tanto si es el
|
||||
// motor elegido como si hace de fondo del mezclador)
|
||||
if (isBeat && butterLive()
|
||||
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "beats") {
|
||||
bcBeatCount++;
|
||||
if (bcBeatCount >= (state.butterchurn.interval || 16)) {
|
||||
|
|
@ -407,9 +416,24 @@ function initButterchurn() {
|
|||
function loadButterchurnPreset(name) {
|
||||
if (!bcPresets[name] || !bcViz) return;
|
||||
bcCurrent = name; // recuerda el intento aunque falle, para no reintentarlo en bucle
|
||||
// El indice sigue al preset que suena: asi 'siguiente' continua desde el
|
||||
// que se ve, tanto si se eligio en la lista como si vino del cambio
|
||||
// automatico.
|
||||
const i = bcNames.indexOf(name);
|
||||
if (i >= 0) bcIndex = i;
|
||||
// El estado guarda siempre el preset que esta en pantalla. Sin esto, el
|
||||
// cambio automatico volvia atras en cuanto llegaba cualquier estado
|
||||
// nuevo (el escenario recargaba el ultimo preset elegido a mano).
|
||||
if (!state || state.butterchurn.preset !== name) {
|
||||
socket.emit("update_settings",
|
||||
{ engine: "butterchurn", patch: { preset: name } });
|
||||
}
|
||||
try {
|
||||
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
|
||||
socket.emit("stage_status", { label: name });
|
||||
socket.emit("stage_status", {
|
||||
label: (state && state.engine === "mixer")
|
||||
? "Mezclador VJ · fondo: " + name : name,
|
||||
});
|
||||
} catch (e) {
|
||||
report("warn", "El preset '" + name + "' fallo al cargar en esta "
|
||||
+ "tarjeta grafica. Pasa a otro con 'siguiente' o eligelo en la lista.");
|
||||
|
|
@ -421,6 +445,17 @@ function bcStep(delta) {
|
|||
loadButterchurnPreset(bcNames[bcIndex]);
|
||||
}
|
||||
|
||||
/* Butterchurn esta pintando ahora mismo? Puede ser el motor elegido o el
|
||||
fondo del mezclador (la capa de abajo). En los dos casos manda el mismo
|
||||
preset y el mismo cambio automatico, para que el fondo del mezclador se
|
||||
maneje igual que el motor Butter. */
|
||||
function butterLive() {
|
||||
if (!state || !state.power) return false;
|
||||
if (state.engine === "butterchurn") return true;
|
||||
return state.engine === "mixer" && state.mixer.fondo === "butter"
|
||||
&& (state.mixer.source === "cam" || state.mixer.source === "mix");
|
||||
}
|
||||
|
||||
function bcLoop() {
|
||||
bcViz.setRendererSize(window.innerWidth, window.innerHeight);
|
||||
bcViz.render();
|
||||
|
|
@ -429,21 +464,30 @@ function bcLoop() {
|
|||
|
||||
function startButterchurn() { if (!bcRAF) bcLoop(); }
|
||||
|
||||
/* Ajustes con los que se monto el temporizador del cambio automatico. Sin
|
||||
esta firma, cada estado nuevo (mover un deslizador, subir un clip...)
|
||||
reiniciaba la cuenta y con intervalos largos el preset no cambiaba nunca. */
|
||||
let bcShuffleSig = "";
|
||||
|
||||
function stopButterchurn() {
|
||||
if (bcRAF) cancelAnimationFrame(bcRAF);
|
||||
bcRAF = null;
|
||||
clearInterval(bcTimer);
|
||||
bcTimer = null;
|
||||
bcShuffleSig = "";
|
||||
}
|
||||
|
||||
function refreshShuffle() {
|
||||
const b = (state && state.butterchurn) || {};
|
||||
const sig = butterLive()
|
||||
? [b.shuffle, b.intervalMode, b.interval].join("|") : "off";
|
||||
if (sig === bcShuffleSig) return;
|
||||
bcShuffleSig = sig;
|
||||
clearInterval(bcTimer);
|
||||
bcTimer = null;
|
||||
bcBeatCount = 0;
|
||||
if (state && state.engine === "butterchurn" && state.power
|
||||
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "seconds") {
|
||||
bcTimer = setInterval(() => bcStep(1),
|
||||
(state.butterchurn.interval || 20) * 1000);
|
||||
if (butterLive() && b.shuffle && b.intervalMode === "seconds") {
|
||||
bcTimer = setInterval(() => bcStep(1), (b.interval || 20) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -515,6 +559,13 @@ let mixerCamFailed = null; // cameraId que fallo (no reintentar en bucle)
|
|||
let mixerVideoEl = null; // elemento <video> del clip actual
|
||||
let mixerWarned = ""; // ultimo aviso de fuente no visible mostrado
|
||||
let mixerButter = false; // s0 es el lienzo de Butterchurn (fondo butter)
|
||||
/* Que hay enganchado a s1 (el clip o la imagen de encima). Hydra.hush() borra
|
||||
TODAS las fuentes al salir del mezclador, asi que hay que recordarlo para
|
||||
volver a engancharlo al entrar: sin esto, al ir al motor Butter (o a
|
||||
projectM, o a Shaders) y volver, el clip desaparecia y la mezcla salia sin
|
||||
la capa de encima. */
|
||||
let mixerS1 = null; // { el, dynamic }
|
||||
let mixerSueltas = false; // hubo hush(): s0/s1 estan vacias
|
||||
|
||||
function stopMixerCam() {
|
||||
if (mixerCamStream) mixerCamStream.getTracks().forEach((t) => t.stop());
|
||||
|
|
@ -530,10 +581,21 @@ function stopMixer() {
|
|||
mixerWarned = "";
|
||||
mixerCamFailed = null;
|
||||
mixerButter = false;
|
||||
mixerSueltas = true;
|
||||
stopMixerCam();
|
||||
if (mixerVideoEl) mixerVideoEl.pause();
|
||||
}
|
||||
|
||||
/* Vuelve a enganchar a Hydra lo que ya teniamos cargado, sin recargarlo. */
|
||||
function reengancharMixer() {
|
||||
if (!mixerSueltas) return;
|
||||
mixerSueltas = false;
|
||||
if (mixerS1 && mixerS1.el) {
|
||||
try { s1.init({ src: mixerS1.el, dynamic: mixerS1.dynamic }); }
|
||||
catch (e) { mixerS1 = null; }
|
||||
}
|
||||
}
|
||||
|
||||
async function initCamSource(index) {
|
||||
const devs = await navigator.mediaDevices.enumerateDevices();
|
||||
const cams = devs.filter((d) => d.kind === "videoinput");
|
||||
|
|
@ -560,6 +622,7 @@ function initVideoSource(name) {
|
|||
vid.addEventListener("loadeddata", () => {
|
||||
vid.play().catch(() => { /* el flag de autoplay del kiosko lo permite */ });
|
||||
s1.init({ src: vid, dynamic: true });
|
||||
mixerS1 = { el: vid, dynamic: true };
|
||||
resolve(vid);
|
||||
});
|
||||
vid.addEventListener("error", () =>
|
||||
|
|
@ -576,6 +639,7 @@ function initImageSource(name) {
|
|||
const img = new Image();
|
||||
img.onload = () => {
|
||||
s1.init({ src: img, dynamic: false });
|
||||
mixerS1 = { el: img, dynamic: false };
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () =>
|
||||
|
|
@ -585,6 +649,9 @@ function initImageSource(name) {
|
|||
}
|
||||
|
||||
async function ensureMixerSources(m) {
|
||||
// Al volver de otro motor, Hydra tiene las fuentes vacias (hush): lo
|
||||
// primero es reengancharlas antes de decidir que falta.
|
||||
reengancharMixer();
|
||||
// Fondo "butter": el lienzo de Butterchurn sigue renderizando (oculto,
|
||||
// el visible es el de Hydra) y hace de capa de abajo s0 en lugar de la
|
||||
// camara. Asi los recortes de personajes van DELANTE de MilkDrop.
|
||||
|
|
@ -592,11 +659,13 @@ async function ensureMixerSources(m) {
|
|||
&& (m.source === "cam" || m.source === "mix");
|
||||
if (wantButter && bcViz) {
|
||||
stopMixerCam();
|
||||
if (!bcCurrent) {
|
||||
loadButterchurnPreset((state && state.butterchurn.preset)
|
||||
|| bcNames[bcIndex]);
|
||||
}
|
||||
// Mismo criterio que en el motor Butter: manda el preset del estado
|
||||
// (elegido en el panel, en el mezclador o por el cambio automatico).
|
||||
const quiere = (state && state.butterchurn.preset) || "";
|
||||
if (quiere && quiere !== bcCurrent) loadButterchurnPreset(quiere);
|
||||
else if (!bcCurrent) loadButterchurnPreset(bcNames[bcIndex]);
|
||||
startButterchurn();
|
||||
refreshShuffle();
|
||||
if (!mixerButter) {
|
||||
mixerButter = true;
|
||||
s0.init({ src: bcCanvas, dynamic: true });
|
||||
|
|
@ -691,6 +760,7 @@ function show(canvas) {
|
|||
msgEl.style.display = canvas ? "none" : "block";
|
||||
// el mapper (si el mapping está activo) usa este canvas como textura
|
||||
if (window.FosMapper) FosMapper.setSource(canvas);
|
||||
if (window.FosLayers) FosLayers.setMotor(canvas);
|
||||
}
|
||||
|
||||
/* Pantalla de conexion: muestra la direccion del panel y un codigo QR en el
|
||||
|
|
@ -700,8 +770,9 @@ function renderInfoScreen() {
|
|||
show(null);
|
||||
const net = state.network || {};
|
||||
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
|
||||
const ipUrl = "http://" + (net.ip || "...") + port + "/";
|
||||
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + port + "/";
|
||||
const k = CLAVE ? "?k=" + encodeURIComponent(CLAVE) : "";
|
||||
const ipUrl = "http://" + (net.ip || "...") + port + "/" + k;
|
||||
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + port + "/" + k;
|
||||
document.getElementById("info-url").textContent = ipUrl;
|
||||
document.getElementById("info-sub").textContent = "o tambien: " + nameUrl;
|
||||
if (typeof qrcode !== "undefined" && lastQrUrl !== ipUrl && net.ip) {
|
||||
|
|
@ -716,11 +787,39 @@ function renderInfoScreen() {
|
|||
document.getElementById("info").style.display = "flex";
|
||||
}
|
||||
|
||||
/* Una capa MilkDrop sin preset elegido coge uno al azar (para que dos capas
|
||||
nuevas no salgan identicas). Lo guardamos en el estado para que el panel
|
||||
ensene cual le toco en vez de dejar el desplegable en blanco. No recrea la
|
||||
instancia: la firma de una capa butter es el tipo a secas. */
|
||||
function anotarPresetDeCapa(surfaceId, preset) {
|
||||
const map = (state && state.mapping) || {};
|
||||
const surf = (map.surfaces || []).find((s) => s.id === surfaceId);
|
||||
if (!surf || (surf.fuente && surf.fuente.preset)) return;
|
||||
surf.fuente = Object.assign({}, surf.fuente, { preset: preset });
|
||||
socket.emit("set_mapping", { surfaces: map.surfaces, masks: map.masks });
|
||||
}
|
||||
|
||||
function applyState(next) {
|
||||
const prevDevice = currentDevice;
|
||||
const prevSource = currentSource;
|
||||
state = next;
|
||||
if (window.FosMapper) FosMapper.setMapping(state.mapping || {});
|
||||
const map = state.mapping || {};
|
||||
// El mapping nunca puede tumbar el escenario: un mapping.json raro dejaria
|
||||
// el proyector en negro y, de paso, sin poder contarlo (los avisos se
|
||||
// pintan despues). Va aislado y con su propio aviso.
|
||||
try {
|
||||
if (window.FosMapper) FosMapper.setMapping(map);
|
||||
// Las capas se ponen al dia con el estado: crea las nuevas, cambia el
|
||||
// preset de las que lo hayan cambiado y tira las borradas. Solo con el
|
||||
// mapping encendido y las visuales dadas: apagado no queremos motores
|
||||
// extra quemando GPU ni la camara con el piloto puesto.
|
||||
if (window.FosLayers) {
|
||||
FosLayers.sync(map.enabled && state.power ? (map.surfaces || []) : []);
|
||||
}
|
||||
} catch (e) {
|
||||
report("error", "El mapping no se pudo aplicar (" + e.message + "). "
|
||||
+ "Pulsa Reset en la pestana MAPPING del panel.");
|
||||
}
|
||||
if (gainNode) gainNode.gain.value = state.sensitivity;
|
||||
|
||||
// Cambio de fuente (micro / audio del navegador) o de tarjeta de audio.
|
||||
|
|
@ -803,17 +902,30 @@ function applyState(next) {
|
|||
// ==========================================================================
|
||||
socket.on("state", (next) => applyState(next));
|
||||
|
||||
/* Al (re)conectar, el escenario le devuelve al servidor lo que solo sabe el
|
||||
navegador: la lista de presets y los dispositivos. Si el servidor se ha
|
||||
reiniciado, su estado nace vacio y sin esto el panel se queda sin presets
|
||||
ni camaras hasta recargar el escenario a mano. */
|
||||
socket.on("connect", () => {
|
||||
if (bcNames.length) socket.emit("stage_meta", { butterchurnPresets: bcNames });
|
||||
enumerateInputs().catch(() => { /* aun sin permisos: ya se reintenta */ });
|
||||
});
|
||||
|
||||
// --- Mapper (projection mapping en el navegador) ---
|
||||
// Arranca el compositor/editor; al editar (arrastrar), guarda en el servidor.
|
||||
// El resolver es lo que hace que cada superficie pinte SU fuente: se la pide
|
||||
// a layers.js, que mantiene una instancia por capa.
|
||||
if (window.FosMapper) {
|
||||
FosMapper.init({
|
||||
onChange: (surfaces) => socket.emit("set_mapping", { surfaces: surfaces }),
|
||||
onChange: (m) => socket.emit("set_mapping", m),
|
||||
resolver: (s) => (window.FosLayers ? FosLayers.fuenteDe(s) : null),
|
||||
report: report,
|
||||
});
|
||||
}
|
||||
|
||||
socket.on("stage_command", (cmd) => {
|
||||
if (!state) return;
|
||||
if (state.engine === "butterchurn") {
|
||||
if (butterLive()) {
|
||||
if (cmd.action === "next") bcStep(1);
|
||||
else if (cmd.action === "prev") bcStep(-1);
|
||||
}
|
||||
|
|
@ -863,6 +975,29 @@ async function boot() {
|
|||
try {
|
||||
shaderEngine = new ShaderEngine(shaderCanvas, analyser);
|
||||
} catch (e) { report("error", "El motor de shaders fallo: " + e.message); }
|
||||
|
||||
// Capas del mapping: cada superficie puede tener su propia fuente. Necesita
|
||||
// el audio compartido (para que MilkDrop y los shaders de cada capa
|
||||
// reaccionen a la misma musica) y la libreria de shaders.
|
||||
let shaders = {};
|
||||
try {
|
||||
shaders = await fetch("/data/shaders.json").then((r) => r.json());
|
||||
} catch (e) {
|
||||
report("warn", "No se pudo cargar la libreria de shaders: las capas de "
|
||||
+ "shader del mapping no estaran disponibles.");
|
||||
}
|
||||
if (window.FosLayers) {
|
||||
FosLayers.init({
|
||||
audioCtx: audioCtx, gainNode: gainNode, analyser: analyser,
|
||||
shaders: shaders, report: report,
|
||||
crearShader: (canvas, size) => new ShaderEngine(canvas, analyser, size),
|
||||
onPreset: anotarPresetDeCapa,
|
||||
});
|
||||
// El estado pudo llegar antes de que las capas estuvieran listas
|
||||
if (state && state.mapping && state.mapping.enabled) {
|
||||
FosLayers.sync(state.mapping.surfaces || []);
|
||||
}
|
||||
}
|
||||
msgEl.textContent = "FOSFENO listo";
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue