chore: auto-commit (43 archivos)
- .mcp.json - bash/functions/infra/write_mcp_jupyter_config.md - bash/functions/infra/write_mcp_jupyter_config.sh - cpp/CMakeLists.txt - cpp/apps/chart_demo - cpp/apps/shaders_lab - cpp/functions/gfx/gl_framebuffer.cpp - cpp/functions/gfx/gl_framebuffer.h - cpp/functions/gfx/gl_framebuffer.md - cpp/functions/gfx/mesh_gpu.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,14 @@
|
|||||||
"registry": {
|
"registry": {
|
||||||
"command": "./apps/registry_mcp/registry_mcp",
|
"command": "./apps/registry_mcp/registry_mcp",
|
||||||
"args": ["--enable-run", "--enable-write"]
|
"args": ["--enable-run", "--enable-write"]
|
||||||
|
},
|
||||||
|
"jupyter": {
|
||||||
|
"command": "/home/lucas/fn_registry/python/.venv/bin/jupyter-mcp-server",
|
||||||
|
"args": [
|
||||||
|
"--transport", "stdio",
|
||||||
|
"--jupyter-url", "http://localhost:8888",
|
||||||
|
"--jupyter-token", ""
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
name: chrome_load_extensions
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "chrome_load_extensions [--port N] [--profile DIR] --ext PATH [--ext PATH ...] [--proxy URL] [--url URL]"
|
||||||
|
description: "Lanza Chrome con extensiones unpacked via --load-extension (WSL2→Windows chrome.exe, paths traducidos, join sin echo, setsid anti-exit-144). OJO: --load-extension SOLO funciona en Chrome for Testing/Chromium/Dev. En Chrome STABLE 138+ esta DESACTIVADO (feature DisableLoadExtensionCommandLineSwitch + bloqueo duro en 148) y carga 0 extensiones aunque el cmdline sea correcto. Para Chrome stable usar install via Web Store (1-clic, persiste en perfil) o enterprise policy ExtensionInstallForcelist (requiere HKLM/HKCU Policies escribible — denegado en maquinas gestionadas)."
|
||||||
|
tags: [chrome, cdp, browser, extensions, wsl2, navegator]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
params:
|
||||||
|
- name: "--port N"
|
||||||
|
desc: "Puerto de remote debugging CDP. Default: 9222."
|
||||||
|
- name: "--profile DIR"
|
||||||
|
desc: "Chrome user-data-dir. Acepta ruta Windows (C:\\...) o ruta WSL/Linux (se traduce via wslpath -w). Default: C:\\Users\\<USERNAME>\\AppData\\Local\\fn-chrome-cdp-profile (WSL2) o /tmp/fn-chrome-cdp-profile (Linux nativo)."
|
||||||
|
- name: "--ext PATH"
|
||||||
|
desc: "Ruta a un directorio de extensión unpacked. Repetible. Acepta ruta Windows (se pasa intacta) o ruta WSL/Linux (se traduce via wslpath -w). Obligatorio al menos uno."
|
||||||
|
- name: "--proxy URL"
|
||||||
|
desc: "Proxy opcional, ej. http://127.0.0.1:8889. Agrega --proxy-server=URL a Chrome."
|
||||||
|
- name: "--url URL"
|
||||||
|
desc: "URL inicial opcional para abrir con --new-window."
|
||||||
|
output: "PID del proceso Chrome lanzado (stdout). Mensajes de estado en stderr. CDP listo en 127.0.0.1:<port>."
|
||||||
|
file_path: "bash/functions/browser/chrome_load_extensions.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/browser/chrome_load_extensions.sh
|
||||||
|
|
||||||
|
chrome_load_extensions \
|
||||||
|
--port 9222 \
|
||||||
|
--profile 'C:\Users\lucas\AppData\Local\fn-chrome-cdp-profile' \
|
||||||
|
--ext 'C:\Users\lucas\hls-dl-ext' \
|
||||||
|
--ext 'C:\Users\lucas\ubol' \
|
||||||
|
--proxy http://127.0.0.1:8889 \
|
||||||
|
--url https://www.gnularetro.cc/
|
||||||
|
```
|
||||||
|
|
||||||
|
Sin proxy ni URL, sólo extensiones:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/browser/chrome_load_extensions.sh
|
||||||
|
|
||||||
|
pid=$(chrome_load_extensions \
|
||||||
|
--ext '/home/lucas/dev/hls-dl-ext' \
|
||||||
|
--ext '/home/lucas/dev/ubol')
|
||||||
|
# Paths WSL traducidos automáticamente a Windows.
|
||||||
|
# CDP listo en 127.0.0.1:9222.
|
||||||
|
echo "Chrome PID: $pid"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites Chrome CDP con extensiones unpacked cargadas (HLS downloader, uBlock Origin, extensiones en desarrollo) y `chrome_launch_go_browser` no sirve porque hardcodea `--disable-extensions`. WSL2→Windows. Ideal para sesiones de navegator con proxy + extensión activa.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **MUERTO en Chrome STABLE 138+ (validado 2026-05-30, Chrome 148)**: `--load-extension` NO carga nada en el canal stable, ni con `--disable-extensions-except` ni con `--disable-features=DisableLoadExtensionCommandLineSwitch`. `chrome://version` muestra el flag correcto pero `chrome://extensions` sale vacío. Google lo bloqueó duro en stable. La función SOLO sirve en **Chrome for Testing / Chromium / Dev/Canary**, donde el switch sigue activo. Para stable: ver opciones abajo.
|
||||||
|
- **Instalar en Chrome STABLE (las que SÍ funcionan)**:
|
||||||
|
1. **Web Store 1-clic** — abre la página del store en el perfil CDP, el humano da "Añadir a Chrome". Persiste en el perfil para siempre (futuros lanzamientos ya con la extensión, sin flags). El popup de confirmación es UI del navegador (no DOM) → NO es CDP-clickable, requiere gesto humano. Único método no-admin que persiste por-perfil.
|
||||||
|
2. **Enterprise policy** `ExtensionInstallForcelist` (HKCU/HKLM `\Software\Policies\Google\Chrome`) — force-install sin clic desde el store, browser-wide. El key `Policies\Google\Chrome` puede dar "Access denied" al escribir (visto 2026-05-30 incluso en máquina personal vía reg.exe/PowerShell desde WSL — Chrome/Windows protege el subárbol Policies). Si funciona, requiere relanzar Chrome para que descargue del store. Método global (afecta todos los perfiles).
|
||||||
|
3. Extensiones **unpacked custom** (no en store, ej. un HLS downloader propio) en stable: no hay vía no-admin. Empaquetar a CRX + self-host `update_url` + policy, o usar Chrome for Testing. A menudo innecesario si la lógica vive fuera (ej. `grab_stream.py` descarga sin extensión).
|
||||||
|
- **Combo flags (solo Chrome for Testing/dev)**: requiere AMBOS `--load-extension=p1,p2` Y `--disable-extensions-except=p1,p2` juntos + `--disable-features=DisableLoadExtensionCommandLineSwitch`. **NUNCA `--disable-extensions`** (desactiva todo).
|
||||||
|
- **join sin `echo`**: rutas Windows `C:\Users\...` tienen `\U`; el `echo` de zsh (o sh con xpg_echo) lo interpreta como escape unicode y trunca la ruta a `C:`. La función usa acumulador `+=`, no `echo`. Verificable en `chrome://version` (debe verse el path completo, no `--load-extension=C:`).
|
||||||
|
- **exit 144 en Bash tool**: si el proceso Chrome retiene el pipe stdout, la herramienta devuelve exit 144. Esta función lanza con `setsid ... </dev/null >log 2>&1 &` + `disown` para desacoplar completamente. El log queda en `/tmp/chrome_ext_<port>.log`.
|
||||||
|
- **WSL2: traducir paths con `wslpath -w`**: los paths de `--ext` y `--profile` que sean rutas Linux se traducen automáticamente. Las rutas Windows (`C:\...`) se pasan intactas. `wslpath` debe estar disponible (estándar en WSL2 desde Windows 10 1903+).
|
||||||
|
- **Perfil ya abierto**: si Chrome ya tiene ese perfil abierto, relanzar añade una ventana extra a la misma instancia. La función detecta si CDP ya responde en el puerto y avisa por stderr, pero procede igualmente.
|
||||||
|
- **Web Store vs unpacked**: instalar extensiones desde la Web Store (un clic) persiste en el perfil sin necesidad de flags y sobrevive reinicios. Esta función es para extensiones unpacked en desarrollo o que no están en la Web Store. Si usas ambas, los flags no interfieren con las instaladas del store.
|
||||||
|
- **zsh globbing**: `--remote-allow-origins=*` está dentro de comillas en la función, no se expande. Si lo pasas desde la línea de comandos, entrecomillarlo.
|
||||||
|
- **Proxy + extensión**: si usas proxy para captura de tráfico (Burp, mitmproxy, gost), el proxy se aplica a toda la sesión Chrome, incluyendo el tráfico de las extensiones.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# chrome_load_extensions — lanza Chrome (WSL2→Windows chrome.exe) con extensiones unpacked cargadas en un perfil CDP.
|
||||||
|
# Chrome 148+: requiere --load-extension=<paths> Y --disable-extensions-except=<same paths> juntos.
|
||||||
|
# NUNCA pasar --disable-extensions (desactiva todo, incluyendo las que quieres cargar).
|
||||||
|
|
||||||
|
chrome_load_extensions() {
|
||||||
|
local port=9222
|
||||||
|
local profile=""
|
||||||
|
local proxy=""
|
||||||
|
local url=""
|
||||||
|
local -a ext_paths=()
|
||||||
|
|
||||||
|
# --- Parse args ---
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--port)
|
||||||
|
port="$2"; shift 2 ;;
|
||||||
|
--profile)
|
||||||
|
profile="$2"; shift 2 ;;
|
||||||
|
--ext)
|
||||||
|
ext_paths+=("$2"); shift 2 ;;
|
||||||
|
--proxy)
|
||||||
|
proxy="$2"; shift 2 ;;
|
||||||
|
--url)
|
||||||
|
url="$2"; shift 2 ;;
|
||||||
|
--*)
|
||||||
|
echo "chrome_load_extensions: flag desconocido: $1" >&2; return 1 ;;
|
||||||
|
*)
|
||||||
|
# Positional = extra ext path
|
||||||
|
ext_paths+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#ext_paths[@]} -eq 0 ]]; then
|
||||||
|
echo "chrome_load_extensions: se requiere al menos un --ext PATH de extension unpacked" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Detectar chrome.exe ---
|
||||||
|
local chrome_bin=""
|
||||||
|
if command -v chrome.exe &>/dev/null; then
|
||||||
|
chrome_bin="chrome.exe"
|
||||||
|
elif [[ -f "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" ]]; then
|
||||||
|
chrome_bin="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
|
||||||
|
elif [[ -f "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" ]]; then
|
||||||
|
chrome_bin="/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
|
||||||
|
else
|
||||||
|
echo "chrome_load_extensions: chrome.exe no encontrado en PATH ni en rutas conocidas" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Detectar WSL2 ---
|
||||||
|
local wsl2=0
|
||||||
|
if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then
|
||||||
|
wsl2=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Traducir paths de extensiones a Windows si hace falta ---
|
||||||
|
local -a win_ext_paths=()
|
||||||
|
for p in "${ext_paths[@]}"; do
|
||||||
|
if [[ $wsl2 -eq 1 ]] && [[ "$p" != [A-Za-z]:\\* ]]; then
|
||||||
|
# Path Linux → traducir a Windows
|
||||||
|
local win_p
|
||||||
|
win_p=$(wslpath -w "$p" 2>/dev/null) || {
|
||||||
|
echo "chrome_load_extensions: wslpath -w '$p' falló" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
win_ext_paths+=("$win_p")
|
||||||
|
else
|
||||||
|
win_ext_paths+=("$p")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Resolver perfil ---
|
||||||
|
if [[ -z "$profile" ]]; then
|
||||||
|
# Default: perfil canónico fn-chrome-cdp-profile en Windows
|
||||||
|
local win_user="${USERNAME:-${USER:-lucas}}"
|
||||||
|
if [[ $wsl2 -eq 1 ]]; then
|
||||||
|
profile="C:\\Users\\${win_user}\\AppData\\Local\\fn-chrome-cdp-profile"
|
||||||
|
else
|
||||||
|
profile="/tmp/fn-chrome-cdp-profile"
|
||||||
|
fi
|
||||||
|
elif [[ $wsl2 -eq 1 ]] && [[ "$profile" != [A-Za-z]:\\* ]]; then
|
||||||
|
# Path Linux del perfil → traducir a Windows
|
||||||
|
profile=$(wslpath -w "$profile" 2>/dev/null) || {
|
||||||
|
echo "chrome_load_extensions: wslpath -w '$profile' falló" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Construir lista de paths separada por coma (para Chrome) ---
|
||||||
|
# Chrome usa coma como separador en --load-extension y --disable-extensions-except.
|
||||||
|
# NO usar `echo` para el join: rutas Windows como C:\Users tienen \U, y el echo de
|
||||||
|
# zsh (o sh con xpg_echo) interpreta \U como escape unicode y trunca la ruta a "C:".
|
||||||
|
# Acumulador con += y printf-safe, sin interpretacion de backslashes.
|
||||||
|
local ext_list=""
|
||||||
|
local p
|
||||||
|
for p in "${win_ext_paths[@]}"; do
|
||||||
|
ext_list+="${ext_list:+,}${p}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Construir args de Chrome ---
|
||||||
|
local -a args=(
|
||||||
|
"--remote-debugging-port=${port}"
|
||||||
|
"--user-data-dir=${profile}"
|
||||||
|
"--no-first-run"
|
||||||
|
"--no-default-browser-check"
|
||||||
|
"--remote-allow-origins=*"
|
||||||
|
"--load-extension=${ext_list}"
|
||||||
|
"--disable-extensions-except=${ext_list}"
|
||||||
|
# Chrome 137+ activa por defecto el feature DisableLoadExtensionCommandLineSwitch,
|
||||||
|
# que IGNORA silenciosamente --load-extension. Hay que desactivarlo o las
|
||||||
|
# extensiones unpacked no cargan (chrome://extensions sale vacio).
|
||||||
|
"--disable-features=DisableLoadExtensionCommandLineSwitch"
|
||||||
|
)
|
||||||
|
|
||||||
|
# WSL2: bind en 0.0.0.0 para que sea accesible desde la red WSL
|
||||||
|
if [[ $wsl2 -eq 1 ]]; then
|
||||||
|
args+=("--remote-debugging-address=0.0.0.0")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$proxy" ]]; then
|
||||||
|
args+=("--proxy-server=${proxy}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$url" ]]; then
|
||||||
|
args+=("--new-window" "$url")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Revisar si CDP ya responde en el puerto ---
|
||||||
|
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
||||||
|
echo "chrome_load_extensions: CDP ya activo en puerto ${port}; lanzando ventana extra" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Lanzar Chrome desacoplado del proceso padre ---
|
||||||
|
# setsid + redirección evita el exit 144 en el Bash tool (el pipe no queda retenido).
|
||||||
|
setsid "$chrome_bin" "${args[@]}" </dev/null >"/tmp/chrome_ext_${port}.log" 2>&1 &
|
||||||
|
local chrome_pid=$!
|
||||||
|
disown "$chrome_pid"
|
||||||
|
|
||||||
|
echo "chrome_load_extensions: Chrome lanzado PID=${chrome_pid} puerto=${port}" >&2
|
||||||
|
|
||||||
|
# --- Esperar a que CDP esté listo (hasta 15 segundos) ---
|
||||||
|
local deadline=$(( $(date +%s) + 15 ))
|
||||||
|
local ready=0
|
||||||
|
while [[ $(date +%s) -lt $deadline ]]; do
|
||||||
|
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
||||||
|
ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $ready -eq 1 ]]; then
|
||||||
|
echo "chrome_load_extensions: CDP listo en 127.0.0.1:${port}"
|
||||||
|
else
|
||||||
|
echo "chrome_load_extensions: advertencia — CDP no respondió en 15s en puerto ${port}; Chrome puede estar iniciando lentamente" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$chrome_pid"
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: start_nordvpn_socks_bridge
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "start_nordvpn_socks_bridge([--port N] [--socks-host HOST] [--socks-port N] [--user U] [--pass P]) -> JSON"
|
||||||
|
description: "Levanta un proxy HTTP local sin auth que reenvía al servidor SOCKS5 de NordVPN con auth usando gost v3. Resuelve la limitación de Chrome, que no soporta SOCKS5-con-auth: el navegador apunta a http://127.0.0.1:<port> (sin auth) y el tráfico sale por NordVPN. Idempotente: si el puerto ya escucha, no relanza."
|
||||||
|
tags: [navegator, vpn, proxy, nordvpn, socks5, gost, chrome, cdp]
|
||||||
|
params:
|
||||||
|
- name: "--port"
|
||||||
|
desc: "Puerto HTTP local del bridge (default 8889)"
|
||||||
|
- name: "--socks-host"
|
||||||
|
desc: "Servidor SOCKS5 de NordVPN (default socks-nl1.nordvpn.com)"
|
||||||
|
- name: "--socks-port"
|
||||||
|
desc: "Puerto del servidor SOCKS5 (default 1080)"
|
||||||
|
- name: "--user"
|
||||||
|
desc: "Service username de NordVPN. Si se omite, lee NORDVPN_SOCKS_USER del entorno"
|
||||||
|
- name: "--pass"
|
||||||
|
desc: "Service password de NordVPN. Si se omite, lee NORDVPN_SOCKS_PASS del entorno"
|
||||||
|
output: "JSON en stdout: {proxy_url, pid, socks_host, status}. status puede ser 'running' (lanzado ahora) o 'already_running' (puerto ya escuchaba). Errores a stderr + exit 1."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/start_nordvpn_socks_bridge.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Con env vars (recomendado para scripts):
|
||||||
|
NORDVPN_SOCKS_USER=xxx NORDVPN_SOCKS_PASS=yyy \
|
||||||
|
bash bash/functions/infra/start_nordvpn_socks_bridge.sh \
|
||||||
|
--port 8889 \
|
||||||
|
--socks-host socks-nl1.nordvpn.com
|
||||||
|
|
||||||
|
# Salida (primera vez):
|
||||||
|
# {"proxy_url":"http://127.0.0.1:8889","pid":12345,"socks_host":"socks-nl1.nordvpn.com","status":"running"}
|
||||||
|
|
||||||
|
# Salida (idempotente, ya corría):
|
||||||
|
# {"proxy_url":"http://127.0.0.1:8889","pid":null,"socks_host":"socks-nl1.nordvpn.com","status":"already_running"}
|
||||||
|
|
||||||
|
# Luego Chrome (o el flujo CDP del navegator) apunta al bridge:
|
||||||
|
# chrome.exe --proxy-server=http://127.0.0.1:8889
|
||||||
|
|
||||||
|
# Verificar que el tráfico sale por NordVPN:
|
||||||
|
# curl -x http://127.0.0.1:8889 https://api.ipify.org
|
||||||
|
# -> 109.202.99.x (IP NordVPN NL, no la IP de casa)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesitas que Chrome (o cualquier app que solo acepta proxy HTTP sin auth) salga por NordVPN, pero NordVPN solo ofrece SOCKS5-con-auth. Chrome no soporta SOCKS5-with-authentication — este bridge actúa de intermediario sin auth local. Útil especialmente en el flujo CDP del navegator (cdp-cli + agente browser) cuando quieres que el browser de automatización salga con IP NordVPN para evadir DPI del ISP o geo-bloqueos, sin exponer las credenciales NordVPN al proceso del browser.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **NordVPN SOCKS5 exige service credentials**, no el usuario/contraseña de la cuenta NordVPN. Se obtienen en dashboard.nordvpn.com → Manual Setup → Service credentials.
|
||||||
|
- **Chrome no soporta SOCKS5-auth** nativamente (a diferencia de Firefox que sí). Por eso este bridge HTTP-sin-auth es necesario.
|
||||||
|
- **gost escucha en todas las interfaces** (`-L http://:PORT`). El puerto local NO tiene autenticación. No exponer en redes no confiables. Para bind solo a loopback cambiar el flag a `-L http://127.0.0.1:PORT` en el script si es necesario.
|
||||||
|
- **Servidores NordVPN SOCKS5 disponibles**: `socks-nl1..8.nordvpn.com`, `socks-de1..4.nordvpn.com`, `socks-us1..8.nordvpn.com`, etc. La lista completa en el dashboard de NordVPN.
|
||||||
|
- **Si gost no está instalado**: se descarga automáticamente `gost v3.0.0 linux amd64` a `~/.local/bin/gost`. Requiere curl y tar.
|
||||||
|
- **Log**: en `/tmp/nordvpn_socks_bridge_<port>.log`. Consultar si el bridge no arranca.
|
||||||
|
- **PID null en already_running**: cuando el puerto ya escuchaba, el PID del proceso no se recupera (habría que hacer `lsof`/`ss` para identificarlo).
|
||||||
|
- **Consumidor principal**: flujo `navegator`/CDP — ver `docs/capabilities/navegator.md`. El agente browser lanza este bridge antes de abrir Chrome con `--proxy-server=http://127.0.0.1:<port>`.
|
||||||
|
- **Gotcha invocación desde el Bash tool de Claude (exit 144)**: el script deja gost en background (`nohup ... & disown`); ese daemon retiene el pipe de stdout del tool → el harness mata el proceso con SIGSTKFLT (exit 144) AUNQUE el bridge SÍ arranca bien. Lanzar con `run_in_background:true` o redirigiendo todo (`>/tmp/x 2>&1 </dev/null`) para evitarlo. En terminal real (o `fn run` interactivo) no ocurre. Verificado 2026-05-30: el bridge queda corriendo y funcional pese al 144.
|
||||||
|
- **Windows→WSL**: si Chrome corre en Windows (chrome.exe) y gost en WSL2, Chrome alcanza `127.0.0.1:<port>` vía localhostForwarding de WSL2. Verificar con `curl -x http://127.0.0.1:<port> https://api.ipify.org` desde ambos lados.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# start_nordvpn_socks_bridge — Levanta proxy HTTP local sin auth que reenvía a SOCKS5 NordVPN con auth via gost v3.
|
||||||
|
# Resuelve la limitacion de Chrome que no soporta SOCKS5-con-auth.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- defaults ---
|
||||||
|
PORT=8889
|
||||||
|
SOCKS_HOST="socks-nl1.nordvpn.com"
|
||||||
|
SOCKS_PORT=1080
|
||||||
|
VPN_USER="${NORDVPN_SOCKS_USER:-}"
|
||||||
|
VPN_PASS="${NORDVPN_SOCKS_PASS:-}"
|
||||||
|
|
||||||
|
# --- parse args ---
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
--socks-host) SOCKS_HOST="$2"; shift 2 ;;
|
||||||
|
--socks-port) SOCKS_PORT="$2"; shift 2 ;;
|
||||||
|
--user) VPN_USER="$2"; shift 2 ;;
|
||||||
|
--pass) VPN_PASS="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown arg: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- validate creds ---
|
||||||
|
if [[ -z "$VPN_USER" ]]; then
|
||||||
|
echo "error: NORDVPN_SOCKS_USER not set. Use --user or export NORDVPN_SOCKS_USER" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$VPN_PASS" ]]; then
|
||||||
|
echo "error: NORDVPN_SOCKS_PASS not set. Use --pass or export NORDVPN_SOCKS_PASS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LOG_FILE="/tmp/nordvpn_socks_bridge_${PORT}.log"
|
||||||
|
|
||||||
|
# --- check idempotencia: ya escucha? ---
|
||||||
|
if ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
|
||||||
|
echo "{\"proxy_url\":\"http://127.0.0.1:${PORT}\",\"pid\":null,\"socks_host\":\"${SOCKS_HOST}\",\"status\":\"already_running\"}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- asegurar gost ---
|
||||||
|
GOST_BIN=""
|
||||||
|
if command -v gost &>/dev/null; then
|
||||||
|
GOST_BIN="$(command -v gost)"
|
||||||
|
elif [[ -x "$HOME/.local/bin/gost" ]]; then
|
||||||
|
GOST_BIN="$HOME/.local/bin/gost"
|
||||||
|
else
|
||||||
|
echo "gost not found, downloading v3.0.0 linux amd64..." >&2
|
||||||
|
GOST_URL="https://github.com/go-gost/gost/releases/download/v3.0.0/gost_3.0.0_linux_amd64.tar.gz"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
curl -fsSL "$GOST_URL" -o "$TMP_DIR/gost.tar.gz" >&2
|
||||||
|
tar -xzf "$TMP_DIR/gost.tar.gz" -C "$TMP_DIR" >&2
|
||||||
|
mkdir -p "$HOME/.local/bin"
|
||||||
|
cp "$TMP_DIR/gost" "$HOME/.local/bin/gost"
|
||||||
|
chmod +x "$HOME/.local/bin/gost"
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
GOST_BIN="$HOME/.local/bin/gost"
|
||||||
|
echo "gost installed to $GOST_BIN" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- url-encode user y pass (puede tener caracteres especiales) ---
|
||||||
|
url_encode() {
|
||||||
|
python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
ENC_USER="$(url_encode "$VPN_USER")"
|
||||||
|
ENC_PASS="$(url_encode "$VPN_PASS")"
|
||||||
|
|
||||||
|
# --- lanzar gost en background ---
|
||||||
|
nohup "$GOST_BIN" \
|
||||||
|
-L "http://:${PORT}" \
|
||||||
|
-F "socks5://${ENC_USER}:${ENC_PASS}@${SOCKS_HOST}:${SOCKS_PORT}" \
|
||||||
|
>"$LOG_FILE" 2>&1 &
|
||||||
|
GOST_PID=$!
|
||||||
|
disown $GOST_PID
|
||||||
|
|
||||||
|
# --- esperar ~2s y verificar que el puerto escucha ---
|
||||||
|
sleep 2
|
||||||
|
if ! ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
|
||||||
|
echo "error: gost did not start. Last lines of $LOG_FILE:" >&2
|
||||||
|
tail -10 "$LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "{\"proxy_url\":\"http://127.0.0.1:${PORT}\",\"pid\":${GOST_PID},\"socks_host\":\"${SOCKS_HOST}\",\"status\":\"running\"}"
|
||||||
@@ -3,11 +3,11 @@ name: write_mcp_jupyter_config
|
|||||||
kind: function
|
kind: function
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: infra
|
domain: infra
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
|
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
|
||||||
description: "Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server apuntando al venv local y puerto dado. Merge con jq si ya existe."
|
description: "Genera o actualiza .mcp.json con la config de jupyter-mcp-server apuntando al console-script del venv local (transport stdio + flags --jupyter-url/--jupyter-token). Merge con jq reemplazando la entrada jupyter entera."
|
||||||
tags: [mcp, jupyter, config, setup, infra]
|
tags: [mcp, jupyter, config, setup, infra, notebook]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -30,10 +30,28 @@ file_path: "bash/functions/infra/write_mcp_jupyter_config.sh"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
source write_mcp_jupyter_config.sh
|
source write_mcp_jupyter_config.sh
|
||||||
path=$(write_mcp_jupyter_config /home/lucas/analysis/finanzas 8890)
|
path=$(write_mcp_jupyter_config /home/lucas/fn_registry/analysis/finanzas 8890)
|
||||||
echo "Config MCP en: $path"
|
echo "Config MCP en: $path"
|
||||||
|
# Genera .mcp.json con:
|
||||||
|
# "command": ".../.venv/bin/jupyter-mcp-server"
|
||||||
|
# "args": ["--transport","stdio","--jupyter-url","http://localhost:8890","--jupyter-token",""]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notas
|
## Cuando usarla
|
||||||
|
|
||||||
El MCP se invoca como modulo Python (`python -m jupyter_mcp_server`) usando el python del venv local, nunca una instalacion global. Si `.mcp.json` ya existe y jq esta disponible, hace merge conservando otros servidores MCP. Sin jq, sobrescribe el archivo.
|
- Al crear un analysis Jupyter nuevo (la usa el pipeline `init_jupyter_analysis`).
|
||||||
|
- Tras mover/recrear un venv y necesitar regenerar el `.mcp.json` del analysis.
|
||||||
|
- Para reparar un `.mcp.json` con el comando viejo roto (`python -m jupyter_mcp_server.server`).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; el proceso importa y sale 0 y el MCP nunca arranca. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`), expuesta como console-script `jupyter-mcp-server`. Sin subcomando arranca en stdio por defecto.
|
||||||
|
- **No usa env vars** `SERVER_URL`/`TOKEN`. La CLI lee flags `--jupyter-url` / `--jupyter-token` (cubren document + runtime). Configs viejas con bloque `env` quedan inertes.
|
||||||
|
- **Tolera Jupyter apagado al boot**: el MCP responde `initialize` tras un connect-timeout (~10s) y sirve igual. Arrancar Jupyter despues en `:port` y los tools se enganchan. No hace falta reiniciar Claude por tener Jupyter caido al inicio.
|
||||||
|
- **Requiere `jupyter-mcp-server` instalado en el venv**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
|
||||||
|
- **Path atado al venv del analysis**: si borras el analysis, ese `.mcp.json` apunta a un binario inexistente. Para un MCP jupyter global e independiente, el `.mcp.json` raiz de `fn_registry` usa el binario del venv canonico `python/.venv/bin/jupyter-mcp-server` (sobrevive el borrado de cualquier analysis).
|
||||||
|
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-05-28) — fix comando roto: console-script `jupyter-mcp-server` + flags stdio en vez de `python -m ...server` + env vars. Merge `+` para reemplazar entrada entera. Tag `notebook`.
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
# write_mcp_jupyter_config
|
# write_mcp_jupyter_config
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
|
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
|
||||||
# Usa el python del venv local con -m jupyter_mcp_server.server.
|
# Usa el console-script `jupyter-mcp-server` del venv local con transport stdio
|
||||||
# Configura via env vars (SERVER_URL, TOKEN) — no CLI args.
|
# y los flags --jupyter-url / --jupyter-token (NO env vars, NO `-m ...server`).
|
||||||
# Hace merge si ya existe .mcp.json (requiere jq).
|
# Hace merge si ya existe .mcp.json (requiere jq).
|
||||||
#
|
#
|
||||||
|
# GOTCHA (2026-05-28): `python -m jupyter_mcp_server.server` NO arranca nada —
|
||||||
|
# server.py no tiene bloque __main__, asi que el proceso importa y sale 0 y el
|
||||||
|
# MCP nunca levanta. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`,
|
||||||
|
# expuesta como console-script `jupyter-mcp-server`), que sin subcomando arranca
|
||||||
|
# en stdio por defecto. La config tampoco lee SERVER_URL/TOKEN: usa los flags
|
||||||
|
# --jupyter-url / --jupyter-token. El MCP tolera que Jupyter este apagado al
|
||||||
|
# arrancar (responde `initialize` tras un connect-timeout ~10s y sirve igual).
|
||||||
|
#
|
||||||
# USO (sourced):
|
# USO (sourced):
|
||||||
# source write_mcp_jupyter_config.sh
|
# source write_mcp_jupyter_config.sh
|
||||||
# write_mcp_jupyter_config /path/to/project 8888
|
# write_mcp_jupyter_config /path/to/project 8888
|
||||||
@@ -17,14 +25,15 @@ write_mcp_jupyter_config() {
|
|||||||
abs_project="$(cd "$project_dir" && pwd)"
|
abs_project="$(cd "$project_dir" && pwd)"
|
||||||
|
|
||||||
local python_bin="${abs_project}/.venv/bin/python"
|
local python_bin="${abs_project}/.venv/bin/python"
|
||||||
|
local mcp_bin="${abs_project}/.venv/bin/jupyter-mcp-server"
|
||||||
if [ ! -f "$python_bin" ]; then
|
if [ ! -f "$python_bin" ]; then
|
||||||
echo "write_mcp_jupyter_config: python no encontrado en ${python_bin}" >&2
|
echo "write_mcp_jupyter_config: python no encontrado en ${python_bin}" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verificar que el modulo esta instalado
|
# Verificar que el console-script esta instalado
|
||||||
if ! "$python_bin" -c "import jupyter_mcp_server" 2>/dev/null; then
|
if [ ! -x "$mcp_bin" ]; then
|
||||||
echo "write_mcp_jupyter_config: jupyter_mcp_server no instalado en el venv" >&2
|
echo "write_mcp_jupyter_config: jupyter-mcp-server no instalado en el venv (${mcp_bin}). Instala con: uv pip install jupyter-mcp-server" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -33,12 +42,12 @@ write_mcp_jupyter_config() {
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"jupyter": {
|
"jupyter": {
|
||||||
"command": "${python_bin}",
|
"command": "${mcp_bin}",
|
||||||
"args": ["-m", "jupyter_mcp_server.server"],
|
"args": [
|
||||||
"env": {
|
"--transport", "stdio",
|
||||||
"SERVER_URL": "http://localhost:${port}",
|
"--jupyter-url", "http://localhost:${port}",
|
||||||
"TOKEN": ""
|
"--jupyter-token", ""
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,8 +55,10 @@ EOF
|
|||||||
)
|
)
|
||||||
|
|
||||||
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
|
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
|
||||||
# Merge conservando otros servidores MCP
|
# Merge conservando otros servidores MCP. Usa `+` (shallow) en el mapa de
|
||||||
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) * (.[1].mcpServers // {}))}' \
|
# servidores para REEMPLAZAR la entrada `jupyter` entera — `*` (deep) dejaba
|
||||||
|
# keys huerfanas de configs viejas (ej. bloque `env` obsoleto).
|
||||||
|
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
|
||||||
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
|
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
|
||||||
mv "${mcp_file}.tmp" "$mcp_file"
|
mv "${mcp_file}.tmp" "$mcp_file"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -553,3 +553,9 @@ if(EXISTS ${_DATA_TABLE_BENCH_DIR}/CMakeLists.txt)
|
|||||||
message(STATUS "Skipping data_table_bench (SQLite3 dev libs not found)")
|
message(STATUS "Skipping data_table_bench (SQLite3 dev libs not found)")
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# --- image_to_3d_studio (lives in projects/imagegen/apps/) ---
|
||||||
|
set(_IMAGE_TO_3D_STUDIO_DIR ${CMAKE_SOURCE_DIR}/../projects/imagegen/apps/image_to_3d_studio)
|
||||||
|
if(EXISTS ${_IMAGE_TO_3D_STUDIO_DIR}/CMakeLists.txt)
|
||||||
|
add_subdirectory(${_IMAGE_TO_3D_STUDIO_DIR} ${CMAKE_BINARY_DIR}/apps/image_to_3d_studio)
|
||||||
|
endif()
|
||||||
|
|||||||
Submodule cpp/apps/chart_demo deleted from 026f514bb7
Submodule cpp/apps/shaders_lab deleted from dc9a970aff
@@ -14,9 +14,17 @@ static void create_tex(Framebuffer& f) {
|
|||||||
glBindTexture(GL_TEXTURE_2D, 0);
|
glBindTexture(GL_TEXTURE_2D, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void create_depth_rbo(Framebuffer& f) {
|
||||||
|
glGenRenderbuffers(1, &f.depth_rbo);
|
||||||
|
glBindRenderbuffer(GL_RENDERBUFFER, f.depth_rbo);
|
||||||
|
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, f.width, f.height);
|
||||||
|
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
||||||
|
}
|
||||||
|
|
||||||
void fb_init(Framebuffer& f) {
|
void fb_init(Framebuffer& f) {
|
||||||
f.width = 1;
|
f.width = 1;
|
||||||
f.height = 1;
|
f.height = 1;
|
||||||
|
f.has_depth = false;
|
||||||
create_tex(f);
|
create_tex(f);
|
||||||
glGenFramebuffers(1, &f.fbo);
|
glGenFramebuffers(1, &f.fbo);
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
||||||
@@ -24,23 +32,50 @@ void fb_init(Framebuffer& f) {
|
|||||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void fb_init_depth(Framebuffer& f) {
|
||||||
|
f.width = 1;
|
||||||
|
f.height = 1;
|
||||||
|
f.has_depth = true;
|
||||||
|
create_tex(f);
|
||||||
|
create_depth_rbo(f);
|
||||||
|
glGenFramebuffers(1, &f.fbo);
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
||||||
|
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, f.tex, 0);
|
||||||
|
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, f.depth_rbo);
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||||
|
}
|
||||||
|
|
||||||
void fb_resize(Framebuffer& f, int w, int h) {
|
void fb_resize(Framebuffer& f, int w, int h) {
|
||||||
if (w == f.width && h == f.height) return;
|
if (w == f.width && h == f.height) return;
|
||||||
f.width = w;
|
f.width = w;
|
||||||
f.height = h;
|
f.height = h;
|
||||||
|
|
||||||
|
// Recreate color texture.
|
||||||
if (f.tex) glDeleteTextures(1, &f.tex);
|
if (f.tex) glDeleteTextures(1, &f.tex);
|
||||||
f.tex = 0;
|
f.tex = 0;
|
||||||
create_tex(f);
|
create_tex(f);
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
||||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, f.tex, 0);
|
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, f.tex, 0);
|
||||||
|
|
||||||
|
// Resize depth renderbuffer in-place (no need to recreate).
|
||||||
|
if (f.has_depth && f.depth_rbo) {
|
||||||
|
glBindRenderbuffer(GL_RENDERBUFFER, f.depth_rbo);
|
||||||
|
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, f.width, f.height);
|
||||||
|
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
||||||
|
// Re-attach in case it was lost (should be stable across storage resize, but be safe).
|
||||||
|
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, f.depth_rbo);
|
||||||
|
}
|
||||||
|
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void fb_destroy(Framebuffer& f) {
|
void fb_destroy(Framebuffer& f) {
|
||||||
if (f.fbo) { glDeleteFramebuffers(1, &f.fbo); f.fbo = 0; }
|
if (f.fbo) { glDeleteFramebuffers(1, &f.fbo); f.fbo = 0; }
|
||||||
if (f.tex) { glDeleteTextures(1, &f.tex); f.tex = 0; }
|
if (f.tex) { glDeleteTextures(1, &f.tex); f.tex = 0; }
|
||||||
f.width = 0;
|
if (f.depth_rbo) { glDeleteRenderbuffers(1, &f.depth_rbo); f.depth_rbo = 0; }
|
||||||
f.height = 0;
|
f.width = 0;
|
||||||
|
f.height = 0;
|
||||||
|
f.has_depth = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace fn::gfx
|
} // namespace fn::gfx
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
namespace fn::gfx {
|
namespace fn::gfx {
|
||||||
|
|
||||||
struct Framebuffer {
|
struct Framebuffer {
|
||||||
unsigned int fbo = 0;
|
unsigned int fbo = 0;
|
||||||
unsigned int tex = 0; // GL_RGBA8, clamp, linear
|
unsigned int tex = 0; // GL_RGBA8 color
|
||||||
int width = 0;
|
unsigned int depth_rbo = 0; // GL_DEPTH_COMPONENT24 renderbuffer, 0 si sin depth
|
||||||
int height = 0;
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
bool has_depth = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
void fb_init(Framebuffer& f); // crea fbo+tex 1x1 iniciales
|
void fb_init(Framebuffer& f); // crea fbo+tex 1x1 (color-only, retro-compat)
|
||||||
void fb_resize(Framebuffer& f, int w, int h); // no-op si w,h iguales
|
void fb_init_depth(Framebuffer& f); // crea fbo+tex+depth_rbo 1x1
|
||||||
void fb_destroy(Framebuffer& f);
|
void fb_resize(Framebuffer& f, int w, int h); // redimensiona color y depth (si has_depth); no-op si iguales
|
||||||
|
void fb_destroy(Framebuffer& f); // libera fbo, tex y depth_rbo si existen
|
||||||
|
|
||||||
} // namespace fn::gfx
|
} // namespace fn::gfx
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: gl_framebuffer
|
|||||||
kind: function
|
kind: function
|
||||||
lang: cpp
|
lang: cpp
|
||||||
domain: gfx
|
domain: gfx
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "void fb_init(Framebuffer& f); void fb_resize(Framebuffer& f, int w, int h); void fb_destroy(Framebuffer& f)"
|
signature: "void fb_init(Framebuffer& f); void fb_init_depth(Framebuffer& f); void fb_resize(Framebuffer& f, int w, int h); void fb_destroy(Framebuffer& f)"
|
||||||
description: "CRUD de un framebuffer OpenGL (FBO + textura RGBA8). fb_resize es no-op si las dimensiones no cambian. Listo para uso con ImGui::Image."
|
description: "CRUD de un framebuffer OpenGL (FBO + textura RGBA8, opcionalmente con depth renderbuffer GL_DEPTH_COMPONENT24). fb_init es color-only (retro-compat); fb_init_depth añade depth. fb_resize redimensiona color y depth si has_depth. Listo para uso con ImGui::Image."
|
||||||
tags: [opengl, framebuffer, fbo, texture, gfx, offscreen]
|
tags: [opengl, framebuffer, fbo, texture, gfx, offscreen, depth, cpp-dashboard-viz]
|
||||||
uses_functions: ["gl_loader_cpp_gfx"]
|
uses_functions: ["gl_loader_cpp_gfx"]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -21,23 +21,23 @@ file_path: "cpp/functions/gfx/gl_framebuffer.cpp"
|
|||||||
framework: opengl
|
framework: opengl
|
||||||
params:
|
params:
|
||||||
- name: f
|
- name: f
|
||||||
desc: "Struct Framebuffer con campos fbo, tex (GL ids), width, height. Inicializar a {0} antes de fb_init."
|
desc: "Struct Framebuffer con campos fbo, tex, depth_rbo (GL ids), width, height, has_depth. Inicializar a {0} antes de fb_init/fb_init_depth."
|
||||||
- name: w
|
- name: w
|
||||||
desc: "Ancho deseado en pixels (fb_resize)"
|
desc: "Ancho deseado en pixels (fb_resize)"
|
||||||
- name: h
|
- name: h
|
||||||
desc: "Alto deseado en pixels (fb_resize)"
|
desc: "Alto deseado en pixels (fb_resize)"
|
||||||
output: "Modifica f in-place. Después de fb_init, f.fbo y f.tex son IDs GL válidos. fb_destroy pone todos los campos a 0."
|
output: "Modifica f in-place. Después de fb_init/fb_init_depth, f.fbo y f.tex son IDs GL válidos. Si fb_init_depth: f.depth_rbo != 0 y f.has_depth == true. fb_destroy pone todos los campos a 0."
|
||||||
---
|
---
|
||||||
|
|
||||||
# gl_framebuffer
|
# gl_framebuffer
|
||||||
|
|
||||||
FBO con textura color RGBA8 (GL_CLAMP_TO_EDGE, GL_LINEAR). Diseñado para renderizado offscreen y posterior display via `ImGui::Image`.
|
FBO con textura color RGBA8 (GL_CLAMP_TO_EDGE, GL_LINEAR). Opcionalmente con depth renderbuffer GL_DEPTH_COMPONENT24. Diseñado para renderizado offscreen y posterior display via `ImGui::Image`.
|
||||||
|
|
||||||
## Ciclo de vida
|
## Ciclo de vida — color-only (retro-compat)
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
fn::gfx::Framebuffer fb{};
|
fn::gfx::Framebuffer fb{};
|
||||||
fn::gfx::fb_init(fb); // fbo + tex 1x1
|
fn::gfx::fb_init(fb); // fbo + tex 1x1, has_depth=false
|
||||||
|
|
||||||
// En el render loop:
|
// En el render loop:
|
||||||
fn::gfx::fb_resize(fb, w, h); // no-op si mismas dimensiones
|
fn::gfx::fb_resize(fb, w, h); // no-op si mismas dimensiones
|
||||||
@@ -46,6 +46,23 @@ fn::gfx::fb_resize(fb, w, h); // no-op si mismas dimensiones
|
|||||||
fn::gfx::fb_destroy(fb);
|
fn::gfx::fb_destroy(fb);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Ciclo de vida — con depth renderbuffer
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
fn::gfx::Framebuffer fb{};
|
||||||
|
fn::gfx::fb_init_depth(fb); // fbo + tex 1x1 + depth_rbo 1x1, has_depth=true
|
||||||
|
|
||||||
|
// En el render loop (antes de glDrawElements):
|
||||||
|
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||||
|
glEnable(GL_DEPTH_TEST);
|
||||||
|
glDepthFunc(GL_LESS);
|
||||||
|
|
||||||
|
fn::gfx::fb_resize(fb, w, h); // redimensiona color Y depth_rbo
|
||||||
|
|
||||||
|
// Al destruir:
|
||||||
|
fn::gfx::fb_destroy(fb); // libera fbo, tex y depth_rbo
|
||||||
|
```
|
||||||
|
|
||||||
## Uso con ImGui::Image
|
## Uso con ImGui::Image
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
@@ -59,4 +76,10 @@ ImGui::Image(
|
|||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
|
|
||||||
`fb_resize` recrea solo la textura (no el FBO) cuando las dimensiones cambian, reattachando la nueva textura al FBO existente. Esto minimiza el overhead de resize.
|
`fb_resize` recrea solo la textura (no el FBO) cuando las dimensiones cambian, reattachando la nueva textura al FBO existente. Para el depth renderbuffer, llama `glRenderbufferStorage` in-place (sin recrear el RBO). Esto minimiza el overhead de resize.
|
||||||
|
|
||||||
|
`fb_init` (sin depth) se mantiene idéntico al comportamiento pre-v1.1.0 — no rompe consumidores existentes (`shader_canvas`, `graph_renderer`).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
v1.1.0 (2026-05-28) — fb_init_depth opcional + depth en fb_resize/fb_destroy
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
#include "gfx/gltf_load_mesh.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// nlohmann/json vendored
|
||||||
|
#include "nlohmann/json.hpp"
|
||||||
|
|
||||||
|
namespace fn::gfx {
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Thread-local last error
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static thread_local char s_last_error[512] = "";
|
||||||
|
|
||||||
|
static void set_error(const char* msg) {
|
||||||
|
std::strncpy(s_last_error, msg, sizeof(s_last_error) - 1);
|
||||||
|
s_last_error[sizeof(s_last_error) - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* gltf_load_last_error() { return s_last_error; }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GLB binary format constants (spec glTF 2.0)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static constexpr uint32_t GLB_MAGIC = 0x46546C67u; // "glTF"
|
||||||
|
static constexpr uint32_t GLB_VERSION = 2u;
|
||||||
|
static constexpr uint32_t CHUNK_JSON = 0x4E4F534Au; // "JSON"
|
||||||
|
static constexpr uint32_t CHUNK_BIN = 0x004E4942u; // "BIN\0"
|
||||||
|
|
||||||
|
// glTF accessor componentType
|
||||||
|
static constexpr int CT_UNSIGNED_BYTE = 5121;
|
||||||
|
static constexpr int CT_UNSIGNED_SHORT = 5123;
|
||||||
|
static constexpr int CT_UNSIGNED_INT = 5125;
|
||||||
|
static constexpr int CT_FLOAT = 5126;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Math helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void cross3(const float a[3], const float b[3], float out[3]) {
|
||||||
|
out[0] = a[1]*b[2] - a[2]*b[1];
|
||||||
|
out[1] = a[2]*b[0] - a[0]*b[2];
|
||||||
|
out[2] = a[0]*b[1] - a[1]*b[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static float dot3(const float a[3], const float b[3]) {
|
||||||
|
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
static float len3(const float a[3]) {
|
||||||
|
return std::sqrt(dot3(a, a));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiply 4x4 column-major matrix by vec3 (point, w=1)
|
||||||
|
static void mat4_mul_point(const float m[16], const float p[3], float out[3]) {
|
||||||
|
out[0] = m[0]*p[0] + m[4]*p[1] + m[8] *p[2] + m[12];
|
||||||
|
out[1] = m[1]*p[0] + m[5]*p[1] + m[9] *p[2] + m[13];
|
||||||
|
out[2] = m[2]*p[0] + m[6]*p[1] + m[10]*p[2] + m[14];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiply 4x4 column-major matrix by vec3 (direction, w=0 — for normals use
|
||||||
|
// inverse-transpose, which here is computed at call site)
|
||||||
|
static void mat4_mul_dir(const float m[16], const float v[3], float out[3]) {
|
||||||
|
out[0] = m[0]*v[0] + m[4]*v[1] + m[8] *v[2];
|
||||||
|
out[1] = m[1]*v[0] + m[5]*v[1] + m[9] *v[2];
|
||||||
|
out[2] = m[2]*v[0] + m[6]*v[1] + m[10]*v[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3x3 inverse-transpose (for normal transform) extracted from upper-left of 4x4.
|
||||||
|
// Returns false if matrix is singular (scale 0).
|
||||||
|
static bool compute_normal_matrix(const float m[16], float out[9]) {
|
||||||
|
// Extract upper-left 3x3 (column-major from 4x4)
|
||||||
|
float a00=m[0], a10=m[1], a20=m[2];
|
||||||
|
float a01=m[4], a11=m[5], a21=m[6];
|
||||||
|
float a02=m[8], a12=m[9], a22=m[10];
|
||||||
|
|
||||||
|
float det = a00*(a11*a22 - a21*a12)
|
||||||
|
- a01*(a10*a22 - a20*a12)
|
||||||
|
+ a02*(a10*a21 - a20*a11);
|
||||||
|
if (std::fabs(det) < 1e-12f) return false;
|
||||||
|
float inv = 1.0f / det;
|
||||||
|
|
||||||
|
// Inverse of 3x3, then transpose → inverse-transpose columns become rows
|
||||||
|
out[0] = inv * (a11*a22 - a21*a12);
|
||||||
|
out[1] = inv * (a21*a02 - a01*a22);
|
||||||
|
out[2] = inv * (a01*a12 - a11*a02);
|
||||||
|
out[3] = inv * (a20*a12 - a10*a22);
|
||||||
|
out[4] = inv * (a00*a22 - a20*a02);
|
||||||
|
out[5] = inv * (a10*a02 - a00*a12);
|
||||||
|
out[6] = inv * (a10*a21 - a20*a11);
|
||||||
|
out[7] = inv * (a20*a01 - a00*a21);
|
||||||
|
out[8] = inv * (a00*a11 - a10*a01);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void nrm3x3_mul(const float m[9], const float v[3], float out[3]) {
|
||||||
|
out[0] = m[0]*v[0] + m[3]*v[1] + m[6]*v[2];
|
||||||
|
out[1] = m[1]*v[0] + m[4]*v[1] + m[7]*v[2];
|
||||||
|
out[2] = m[2]*v[0] + m[5]*v[1] + m[8]*v[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRS → column-major 4x4 matrix
|
||||||
|
// translation=[tx,ty,tz], rotation quaternion=[qx,qy,qz,qw], scale=[sx,sy,sz]
|
||||||
|
static void trs_to_mat4(const float t[3], const float q[4], const float s[3],
|
||||||
|
float out[16]) {
|
||||||
|
float qx=q[0], qy=q[1], qz=q[2], qw=q[3];
|
||||||
|
float x2=qx+qx, y2=qy+qy, z2=qz+qz;
|
||||||
|
float xx=qx*x2, xy=qx*y2, xz=qx*z2;
|
||||||
|
float yy=qy*y2, yz=qy*z2, zz=qz*z2;
|
||||||
|
float wx=qw*x2, wy=qw*y2, wz=qw*z2;
|
||||||
|
|
||||||
|
out[0] = (1-(yy+zz))*s[0]; out[1] = (xy+wz)*s[0]; out[2] = (xz-wy)*s[0]; out[3] = 0;
|
||||||
|
out[4] = (xy-wz)*s[1]; out[5] = (1-(xx+zz))*s[1]; out[6] = (yz+wx)*s[1]; out[7] = 0;
|
||||||
|
out[8] = (xz+wy)*s[2]; out[9] = (yz-wx)*s[2]; out[10] = (1-(xx+yy))*s[2]; out[11] = 0;
|
||||||
|
out[12] = t[0]; out[13] = t[1]; out[14] = t[2]; out[15] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accessor reading helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct BufView {
|
||||||
|
const uint8_t* base = nullptr;
|
||||||
|
size_t total = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read a single element of 'count' components from accessor at element index 'idx'.
|
||||||
|
// component_type: CT_FLOAT, CT_UNSIGNED_BYTE, CT_UNSIGNED_SHORT, CT_UNSIGNED_INT
|
||||||
|
// components_per_element: 1 (SCALAR) or 3 (VEC3) etc.
|
||||||
|
// Returns false if out-of-bounds.
|
||||||
|
static bool read_float_vec(const BufView& bin,
|
||||||
|
int component_type,
|
||||||
|
int components_per_element,
|
||||||
|
size_t byte_offset, // accessor.byteOffset + bufferView.byteOffset
|
||||||
|
int byte_stride, // bufferView.byteStride (0 = tightly packed)
|
||||||
|
size_t idx,
|
||||||
|
float out[4]) {
|
||||||
|
size_t comp_size = 0;
|
||||||
|
switch (component_type) {
|
||||||
|
case CT_UNSIGNED_BYTE: comp_size = 1; break;
|
||||||
|
case CT_UNSIGNED_SHORT: comp_size = 2; break;
|
||||||
|
case CT_UNSIGNED_INT: comp_size = 4; break;
|
||||||
|
case CT_FLOAT: comp_size = 4; break;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
size_t element_size = comp_size * (size_t)components_per_element;
|
||||||
|
size_t stride = (byte_stride > 0) ? (size_t)byte_stride : element_size;
|
||||||
|
size_t off = byte_offset + idx * stride;
|
||||||
|
if (off + element_size > bin.total) return false;
|
||||||
|
|
||||||
|
const uint8_t* p = bin.base + off;
|
||||||
|
for (int c = 0; c < components_per_element; ++c) {
|
||||||
|
const uint8_t* cp = p + (size_t)c * comp_size;
|
||||||
|
switch (component_type) {
|
||||||
|
case CT_UNSIGNED_BYTE: out[c] = (float)*cp; break;
|
||||||
|
case CT_UNSIGNED_SHORT: {
|
||||||
|
uint16_t v; std::memcpy(&v, cp, 2); out[c] = (float)v; break;
|
||||||
|
}
|
||||||
|
case CT_UNSIGNED_INT: {
|
||||||
|
uint32_t v; std::memcpy(&v, cp, 4); out[c] = (float)v; break;
|
||||||
|
}
|
||||||
|
case CT_FLOAT: {
|
||||||
|
float v; std::memcpy(&v, cp, 4); out[c] = v; break;
|
||||||
|
}
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool read_index(const BufView& bin,
|
||||||
|
int component_type,
|
||||||
|
size_t byte_offset,
|
||||||
|
size_t idx,
|
||||||
|
uint32_t& out) {
|
||||||
|
float v[1] = {};
|
||||||
|
if (!read_float_vec(bin, component_type, 1, byte_offset, 0, idx, v))
|
||||||
|
return false;
|
||||||
|
out = static_cast<uint32_t>(v[0]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core GLB parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static Mesh parse_glb(const uint8_t* data, size_t size) {
|
||||||
|
s_last_error[0] = '\0';
|
||||||
|
|
||||||
|
// --- 1. Validate header (12 bytes) ---
|
||||||
|
if (size < 12) { set_error("file too small for GLB header"); return {}; }
|
||||||
|
|
||||||
|
uint32_t magic, version, total_len;
|
||||||
|
std::memcpy(&magic, data, 4);
|
||||||
|
std::memcpy(&version, data + 4, 4);
|
||||||
|
std::memcpy(&total_len, data + 8, 4);
|
||||||
|
|
||||||
|
if (magic != GLB_MAGIC) { set_error("not a GLB file (bad magic)"); return {}; }
|
||||||
|
if (version != GLB_VERSION){ set_error("unsupported GLB version (expected 2)"); return {}; }
|
||||||
|
if (total_len > size) { set_error("GLB total_length > buffer size"); return {}; }
|
||||||
|
|
||||||
|
// --- 2. Walk chunks ---
|
||||||
|
const uint8_t* json_data = nullptr; size_t json_len = 0;
|
||||||
|
const uint8_t* bin_data = nullptr; size_t bin_len = 0;
|
||||||
|
|
||||||
|
size_t pos = 12;
|
||||||
|
while (pos + 8 <= total_len) {
|
||||||
|
uint32_t chunk_len, chunk_type;
|
||||||
|
std::memcpy(&chunk_len, data + pos, 4);
|
||||||
|
std::memcpy(&chunk_type, data + pos + 4, 4);
|
||||||
|
pos += 8;
|
||||||
|
if (pos + chunk_len > total_len) { set_error("chunk extends past file end"); return {}; }
|
||||||
|
if (chunk_type == CHUNK_JSON) {
|
||||||
|
json_data = data + pos;
|
||||||
|
json_len = chunk_len;
|
||||||
|
} else if (chunk_type == CHUNK_BIN) {
|
||||||
|
bin_data = data + pos;
|
||||||
|
bin_len = chunk_len;
|
||||||
|
}
|
||||||
|
pos += chunk_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!json_data) { set_error("no JSON chunk found"); return {}; }
|
||||||
|
|
||||||
|
// --- 3. Parse JSON ---
|
||||||
|
nlohmann::json j;
|
||||||
|
try {
|
||||||
|
j = nlohmann::json::parse(json_data, json_data + json_len);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::snprintf(s_last_error, sizeof(s_last_error), "JSON parse error: %s", e.what());
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Find first mesh / first primitive ---
|
||||||
|
if (!j.contains("meshes") || j["meshes"].empty()) {
|
||||||
|
set_error("no meshes in glTF");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
auto& prim = j["meshes"][0]["primitives"][0];
|
||||||
|
|
||||||
|
auto& attrs = prim["attributes"];
|
||||||
|
if (!attrs.contains("POSITION")) {
|
||||||
|
set_error("primitive has no POSITION attribute");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto& accessors = j["accessors"];
|
||||||
|
auto& bufferViews = j["bufferViews"];
|
||||||
|
|
||||||
|
BufView bin_view { bin_data, bin_len };
|
||||||
|
|
||||||
|
// Helper: resolve accessor index → (byte_offset, byte_stride, component_type, count, components_per_elem)
|
||||||
|
struct AccInfo { size_t byte_offset; int byte_stride; int comp_type; size_t count; int ncomp; };
|
||||||
|
|
||||||
|
auto resolve_accessor = [&](int acc_idx, AccInfo& out) -> bool {
|
||||||
|
if (acc_idx < 0 || acc_idx >= (int)accessors.size()) return false;
|
||||||
|
auto& acc = accessors[acc_idx];
|
||||||
|
int bv_idx = acc.value("bufferView", -1);
|
||||||
|
size_t acc_offset = acc.value("byteOffset", 0);
|
||||||
|
out.comp_type = acc.value("componentType", 0);
|
||||||
|
out.count = acc.value("count", 0u);
|
||||||
|
std::string type_str = acc.value("type", "SCALAR");
|
||||||
|
out.ncomp = 1;
|
||||||
|
if (type_str == "VEC2") out.ncomp = 2;
|
||||||
|
else if (type_str == "VEC3") out.ncomp = 3;
|
||||||
|
else if (type_str == "VEC4") out.ncomp = 4;
|
||||||
|
|
||||||
|
if (bv_idx >= 0 && bv_idx < (int)bufferViews.size()) {
|
||||||
|
auto& bv = bufferViews[bv_idx];
|
||||||
|
size_t bv_offset = bv.value("byteOffset", 0u);
|
||||||
|
out.byte_stride = bv.value("byteStride", 0);
|
||||||
|
out.byte_offset = acc_offset + bv_offset;
|
||||||
|
} else {
|
||||||
|
out.byte_offset = acc_offset;
|
||||||
|
out.byte_stride = 0;
|
||||||
|
}
|
||||||
|
return out.count > 0 && out.comp_type != 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 5. Read POSITION ---
|
||||||
|
AccInfo pos_info{};
|
||||||
|
if (!resolve_accessor(attrs["POSITION"].get<int>(), pos_info)) {
|
||||||
|
set_error("failed to resolve POSITION accessor");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (pos_info.ncomp != 3 || pos_info.comp_type != CT_FLOAT) {
|
||||||
|
set_error("POSITION must be float vec3");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!bin_data && pos_info.count > 0) {
|
||||||
|
set_error("POSITION accessor requires BIN chunk, which is missing");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t nv = pos_info.count;
|
||||||
|
std::vector<float> positions(nv * 3);
|
||||||
|
for (size_t i = 0; i < nv; ++i) {
|
||||||
|
float v[4]{};
|
||||||
|
if (!read_float_vec(bin_view, CT_FLOAT, 3, pos_info.byte_offset,
|
||||||
|
pos_info.byte_stride, i, v)) {
|
||||||
|
set_error("out-of-bounds read in POSITION");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
positions[i*3+0] = v[0];
|
||||||
|
positions[i*3+1] = v[1];
|
||||||
|
positions[i*3+2] = v[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. Read NORMAL (optional) ---
|
||||||
|
std::vector<float> normals;
|
||||||
|
bool has_normals = false;
|
||||||
|
if (attrs.contains("NORMAL")) {
|
||||||
|
AccInfo nrm_info{};
|
||||||
|
if (resolve_accessor(attrs["NORMAL"].get<int>(), nrm_info) &&
|
||||||
|
nrm_info.ncomp == 3 && nrm_info.comp_type == CT_FLOAT &&
|
||||||
|
nrm_info.count == nv) {
|
||||||
|
normals.resize(nv * 3);
|
||||||
|
for (size_t i = 0; i < nv; ++i) {
|
||||||
|
float v[4]{};
|
||||||
|
if (!read_float_vec(bin_view, CT_FLOAT, 3, nrm_info.byte_offset,
|
||||||
|
nrm_info.byte_stride, i, v)) {
|
||||||
|
set_error("out-of-bounds read in NORMAL");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
normals[i*3+0] = v[0];
|
||||||
|
normals[i*3+1] = v[1];
|
||||||
|
normals[i*3+2] = v[2];
|
||||||
|
}
|
||||||
|
has_normals = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 7. Read indices ---
|
||||||
|
std::vector<uint32_t> indices;
|
||||||
|
if (prim.contains("indices") && !prim["indices"].is_null()) {
|
||||||
|
AccInfo idx_info{};
|
||||||
|
int idx_acc = prim["indices"].get<int>();
|
||||||
|
if (!resolve_accessor(idx_acc, idx_info)) {
|
||||||
|
set_error("failed to resolve indices accessor");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!bin_data && idx_info.count > 0) {
|
||||||
|
set_error("indices accessor requires BIN chunk, which is missing");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
indices.resize(idx_info.count);
|
||||||
|
for (size_t i = 0; i < idx_info.count; ++i) {
|
||||||
|
if (!read_index(bin_view, idx_info.comp_type, idx_info.byte_offset, i, indices[i])) {
|
||||||
|
set_error("out-of-bounds read in indices");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No indices: interpret as sequential triangle list
|
||||||
|
indices.resize(nv);
|
||||||
|
for (size_t i = 0; i < nv; ++i) indices[i] = (uint32_t)i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 8. Generate normals if missing (smooth, area-weighted) ---
|
||||||
|
if (!has_normals) {
|
||||||
|
normals.assign(nv * 3, 0.0f);
|
||||||
|
size_t ntri = indices.size() / 3;
|
||||||
|
for (size_t t = 0; t < ntri; ++t) {
|
||||||
|
uint32_t i0 = indices[t*3+0];
|
||||||
|
uint32_t i1 = indices[t*3+1];
|
||||||
|
uint32_t i2 = indices[t*3+2];
|
||||||
|
if (i0 >= nv || i1 >= nv || i2 >= nv) continue;
|
||||||
|
|
||||||
|
float e1[3] = {
|
||||||
|
positions[i1*3+0] - positions[i0*3+0],
|
||||||
|
positions[i1*3+1] - positions[i0*3+1],
|
||||||
|
positions[i1*3+2] - positions[i0*3+2]
|
||||||
|
};
|
||||||
|
float e2[3] = {
|
||||||
|
positions[i2*3+0] - positions[i0*3+0],
|
||||||
|
positions[i2*3+1] - positions[i0*3+1],
|
||||||
|
positions[i2*3+2] - positions[i0*3+2]
|
||||||
|
};
|
||||||
|
float face_n[3];
|
||||||
|
cross3(e1, e2, face_n);
|
||||||
|
// face_n magnitude = 2 * area → area weighting automatic
|
||||||
|
for (uint32_t vi : {i0, i1, i2}) {
|
||||||
|
normals[vi*3+0] += face_n[0];
|
||||||
|
normals[vi*3+1] += face_n[1];
|
||||||
|
normals[vi*3+2] += face_n[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normalize per-vertex
|
||||||
|
for (size_t i = 0; i < nv; ++i) {
|
||||||
|
float* n = &normals[i*3];
|
||||||
|
float l = len3(n);
|
||||||
|
if (l > 1e-8f) { n[0]/=l; n[1]/=l; n[2]/=l; }
|
||||||
|
else { n[0]=0; n[1]=1; n[2]=0; } // degenerate fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 9. Apply node transform (first node referencing this mesh) ---
|
||||||
|
bool applied_transform = false;
|
||||||
|
if (j.contains("nodes") && !j["nodes"].empty()) {
|
||||||
|
auto& nodes = j["nodes"];
|
||||||
|
for (size_t ni = 0; ni < nodes.size() && !applied_transform; ++ni) {
|
||||||
|
auto& node = nodes[ni];
|
||||||
|
if (!node.contains("mesh") || node["mesh"].get<int>() != 0) continue;
|
||||||
|
|
||||||
|
float mat[16] = {
|
||||||
|
1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1
|
||||||
|
}; // identity column-major
|
||||||
|
|
||||||
|
if (node.contains("matrix") && node["matrix"].is_array() && node["matrix"].size() == 16) {
|
||||||
|
for (int k = 0; k < 16; ++k)
|
||||||
|
mat[k] = node["matrix"][k].get<float>();
|
||||||
|
applied_transform = true;
|
||||||
|
} else {
|
||||||
|
float t[3] = {0,0,0}, q[4] = {0,0,0,1}, s[3] = {1,1,1};
|
||||||
|
bool has_trs = false;
|
||||||
|
if (node.contains("translation") && node["translation"].size() == 3) {
|
||||||
|
for (int k = 0; k < 3; ++k) t[k] = node["translation"][k].get<float>();
|
||||||
|
has_trs = true;
|
||||||
|
}
|
||||||
|
if (node.contains("rotation") && node["rotation"].size() == 4) {
|
||||||
|
for (int k = 0; k < 4; ++k) q[k] = node["rotation"][k].get<float>();
|
||||||
|
has_trs = true;
|
||||||
|
}
|
||||||
|
if (node.contains("scale") && node["scale"].size() == 3) {
|
||||||
|
for (int k = 0; k < 3; ++k) s[k] = node["scale"][k].get<float>();
|
||||||
|
has_trs = true;
|
||||||
|
}
|
||||||
|
if (has_trs) {
|
||||||
|
trs_to_mat4(t, q, s, mat);
|
||||||
|
applied_transform = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applied_transform) {
|
||||||
|
// Check if matrix is non-trivially identity
|
||||||
|
const float id[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
|
||||||
|
bool is_identity = true;
|
||||||
|
for (int k = 0; k < 16; ++k)
|
||||||
|
if (std::fabs(mat[k] - id[k]) > 1e-6f) { is_identity = false; break; }
|
||||||
|
|
||||||
|
if (!is_identity) {
|
||||||
|
float nrm_mat[9];
|
||||||
|
bool has_nrm_mat = compute_normal_matrix(mat, nrm_mat);
|
||||||
|
|
||||||
|
for (size_t vi = 0; vi < nv; ++vi) {
|
||||||
|
float p[3] = { positions[vi*3+0], positions[vi*3+1], positions[vi*3+2] };
|
||||||
|
float tp[3];
|
||||||
|
mat4_mul_point(mat, p, tp);
|
||||||
|
positions[vi*3+0] = tp[0];
|
||||||
|
positions[vi*3+1] = tp[1];
|
||||||
|
positions[vi*3+2] = tp[2];
|
||||||
|
|
||||||
|
if (has_nrm_mat) {
|
||||||
|
float n[3] = { normals[vi*3+0], normals[vi*3+1], normals[vi*3+2] };
|
||||||
|
float tn[3];
|
||||||
|
nrm3x3_mul(nrm_mat, n, tn);
|
||||||
|
float l = len3(tn);
|
||||||
|
if (l > 1e-8f) { tn[0]/=l; tn[1]/=l; tn[2]/=l; }
|
||||||
|
normals[vi*3+0] = tn[0];
|
||||||
|
normals[vi*3+1] = tn[1];
|
||||||
|
normals[vi*3+2] = tn[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Mesh m;
|
||||||
|
m.positions = std::move(positions);
|
||||||
|
m.normals = std::move(normals);
|
||||||
|
m.indices = std::move(indices);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size) {
|
||||||
|
return parse_glb(reinterpret_cast<const uint8_t*>(data), size);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mesh gltf_load_mesh_from_file(const char* path) {
|
||||||
|
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||||
|
if (!f) {
|
||||||
|
std::snprintf(s_last_error, sizeof(s_last_error),
|
||||||
|
"cannot open file: %s", path);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
auto file_size = f.tellg();
|
||||||
|
if (file_size <= 0) { set_error("file is empty"); return {}; }
|
||||||
|
f.seekg(0);
|
||||||
|
std::vector<uint8_t> buf((size_t)file_size);
|
||||||
|
if (!f.read(reinterpret_cast<char*>(buf.data()), file_size)) {
|
||||||
|
set_error("file read failed");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return parse_glb(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fn::gfx
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "gfx/mesh_obj_load.h" // fn::gfx::Mesh
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace fn::gfx {
|
||||||
|
|
||||||
|
// Carga el primer mesh (primera primitive del primer mesh) de un archivo GLB 2.0.
|
||||||
|
//
|
||||||
|
// Soporta:
|
||||||
|
// - POSITION (vec3 float, obligatorio)
|
||||||
|
// - NORMAL (vec3 float, opcional — si falta se generan normales smooth
|
||||||
|
// area-weighted promediando las normales de cara de cada vertice)
|
||||||
|
// - indices (ubyte/ushort/uint, escalares) — sin indices se interpreta como
|
||||||
|
// lista de triangulos directa.
|
||||||
|
//
|
||||||
|
// Node transform: si el primer nodo que referencia el mesh tiene matrix o TRS,
|
||||||
|
// se aplica a posiciones y normales (normales se transforman con la inversa transpuesta).
|
||||||
|
//
|
||||||
|
// Limitaciones (documentadas):
|
||||||
|
// - Solo GLB (binario). .gltf+.bin separado y data-URIs base64 no soportados.
|
||||||
|
// - Solo el primer mesh / primera primitive.
|
||||||
|
// - Sin texturas ni materiales (mesh viewer usa color uniforme).
|
||||||
|
// - Asume buffer 0 embebido en el chunk BIN.
|
||||||
|
//
|
||||||
|
// Retorna Mesh vacio (positions.empty()) si el parse falla.
|
||||||
|
// El detalle del error esta disponible via gltf_load_last_error().
|
||||||
|
Mesh gltf_load_mesh_from_file(const char* path);
|
||||||
|
|
||||||
|
// Variante pura (salvo el buffer): parsea GLB desde un bloque de memoria.
|
||||||
|
// 'data' debe vivir al menos mientras dure la llamada.
|
||||||
|
// Retorna Mesh vacio en fallo; gltf_load_last_error() da el detalle.
|
||||||
|
Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size);
|
||||||
|
|
||||||
|
// Descripcion del ultimo error de gltf_load_mesh_from_file /
|
||||||
|
// gltf_load_mesh_from_memory. Valida hasta la siguiente llamada a cualquiera
|
||||||
|
// de las dos funciones. Nunca retorna nullptr (puede ser "").
|
||||||
|
const char* gltf_load_last_error();
|
||||||
|
|
||||||
|
} // namespace fn::gfx
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: gltf_load_mesh
|
||||||
|
kind: function
|
||||||
|
lang: cpp
|
||||||
|
domain: gfx
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "Mesh gltf_load_mesh_from_file(const char* path); Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size); const char* gltf_load_last_error()"
|
||||||
|
description: "Parser GLB 2.0 (glTF binario): carga el primer mesh/primitive a CPU como fn::gfx::Mesh. Soporta POSITION+NORMAL (vec3 float), indices ubyte/ushort/uint, node transform TRS/matrix. Genera normales smooth area-weighted si faltan. Sin dependencias externas — BIN chunk + nlohmann JSON vendored."
|
||||||
|
tags: [mesh, gltf, glb, 3d, loader, geometry, gfx, mesh-3d]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [gfx/mesh_obj_load.h, nlohmann/json.hpp, fstream, cstring, cmath]
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "invalid magic -> empty Mesh + last_error set"
|
||||||
|
- "too-small buffer -> empty Mesh + last_error set"
|
||||||
|
- "triangle without NORMAL -> normals generated, correct count"
|
||||||
|
- "quad (2 triangles) -> positions.size()==12, indices.size()==6"
|
||||||
|
- "explicit normals -> passed through unchanged"
|
||||||
|
- "nonexistent file -> empty Mesh + last_error set"
|
||||||
|
test_file_path: "cpp/tests/test_gltf_load_mesh.cpp"
|
||||||
|
file_path: "cpp/functions/gfx/gltf_load_mesh.cpp"
|
||||||
|
framework: opengl
|
||||||
|
params:
|
||||||
|
- name: path
|
||||||
|
desc: "Ruta al archivo .glb. Solo GLB binario — .gltf+.bin separado y data-URI base64 no soportados."
|
||||||
|
- name: data
|
||||||
|
desc: "Puntero al buffer GLB en memoria. Debe vivir mientras dure la llamada."
|
||||||
|
- name: size
|
||||||
|
desc: "Longitud del buffer en bytes."
|
||||||
|
output: "fn::gfx::Mesh con positions/normals (stride 3, mismo length) y indices uint32 (tri-list). Mesh vacio (positions.empty()==true) si parse falla. gltf_load_last_error() devuelve descripcion del error."
|
||||||
|
notes: |
|
||||||
|
Usa fn::gfx::Mesh de mesh_obj_load.h — mismo struct que consume mesh_gpu_upload().
|
||||||
|
nlohmann vendored en cpp/vendor/nlohmann/json.hpp.
|
||||||
|
El parser no aloca heap mas alla del Mesh de salida + JSON temporal.
|
||||||
|
gltf_load_last_error() usa thread_local — seguro en multihilo siempre que
|
||||||
|
cada hilo llame sus propias funciones.
|
||||||
|
---
|
||||||
|
|
||||||
|
# gltf_load_mesh
|
||||||
|
|
||||||
|
Loader GLB 2.0 minimal para el registry. Parsea el contenedor GLB binario a mano
|
||||||
|
(header 12 bytes + chunks JSON + BIN) usando nlohmann para el JSON. KISS: sin
|
||||||
|
tinygltf ni dependencias extra.
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Cargar .glb generado por TripoSR/trimesh y subir a GPU:
|
||||||
|
#include "gfx/gltf_load_mesh.h"
|
||||||
|
#include "gfx/mesh_gpu.h"
|
||||||
|
|
||||||
|
auto cpu = fn::gfx::gltf_load_mesh_from_file("model.glb");
|
||||||
|
if (cpu.positions.empty()) {
|
||||||
|
fprintf(stderr, "gltf load failed: %s\n", fn::gfx::gltf_load_last_error());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subir a GPU (requiere contexto GL activo):
|
||||||
|
auto gpu = fn::gfx::mesh_gpu_upload(cpu);
|
||||||
|
if (!gpu.ok()) { /* fallo de upload GL */ return; }
|
||||||
|
|
||||||
|
glUseProgram(prog);
|
||||||
|
glBindVertexArray(gpu.vao);
|
||||||
|
glDrawElements(GL_TRIANGLES, gpu.index_count, GL_UNSIGNED_INT, 0);
|
||||||
|
glBindVertexArray(0);
|
||||||
|
|
||||||
|
fn::gfx::mesh_gpu_destroy(gpu);
|
||||||
|
```
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Desde memoria (ej. respuesta HTTP o embedding):
|
||||||
|
std::vector<unsigned char> glb_buf = download_glb(...);
|
||||||
|
auto cpu = fn::gfx::gltf_load_mesh_from_memory(glb_buf.data(), glb_buf.size());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando recibes un `.glb` (binario glTF 2.0) de un backend Python (TripoSR,
|
||||||
|
trimesh, open3d) y necesitas renderizarlo en una app ImGui via `mesh_gpu_upload`.
|
||||||
|
Tambien util para inspeccionar geometria en CPU sin subir a GPU.
|
||||||
|
|
||||||
|
## Limitaciones
|
||||||
|
|
||||||
|
- **Solo GLB binario**. `.gltf + .bin` separado: no soportado. Data URIs base64: no soportados.
|
||||||
|
- **Primer mesh, primera primitive**. Archivos con multiples meshes o materiales: solo se carga el primero.
|
||||||
|
- **Sin texturas ni materiales**. El Mesh solo contiene geometria (posicion + normal). El shader del viewer usa color uniforme.
|
||||||
|
- **Buffer unico embebido** (chunk BIN). Referencias a buffers externos: no soportadas.
|
||||||
|
- **Modo solo triangulos** (`"mode": 4`, default). Puntos, lineas, triangle-strip: no soportados.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `gltf_load_last_error()` es `thread_local`. Si usas multihilo, cada hilo tiene su propio error buffer — no compartas el puntero entre hilos.
|
||||||
|
- El puntero que devuelve `gltf_load_last_error()` se sobreescribe en la siguiente llamada a `gltf_load_mesh_from_*`. Copia el string si lo necesitas despues.
|
||||||
|
- Un `Mesh` retornado con `positions.empty() == true` es la senal de fallo — **no** lanzamos excepciones.
|
||||||
|
- Para archivos grandes (>50 MB) la lectura es un `std::vector<uint8_t>` completo en memoria. Para streaming, usa `gltf_load_mesh_from_memory` con tu propio buffer.
|
||||||
|
- El parser no valida que `indices` sean menores que `nv` en cada vertice — indices fuera de rango se saltan silenciosamente durante la generacion de normales pero pueden producir geometria incorrecta.
|
||||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
|||||||
purity: impure
|
purity: impure
|
||||||
signature: "MeshGpu mesh_gpu_upload(const Mesh&); void mesh_gpu_destroy(MeshGpu&)"
|
signature: "MeshGpu mesh_gpu_upload(const Mesh&); void mesh_gpu_destroy(MeshGpu&)"
|
||||||
description: "Sube un Mesh CPU a OpenGL como VAO + VBO interleaved (pos.xyz, normal.xyz) + EBO uint32. Layout: location 0 = a_pos vec3, location 1 = a_normal vec3, stride 6 floats."
|
description: "Sube un Mesh CPU a OpenGL como VAO + VBO interleaved (pos.xyz, normal.xyz) + EBO uint32. Layout: location 0 = a_pos vec3, location 1 = a_normal vec3, stride 6 floats."
|
||||||
tags: [opengl, mesh, vao, vbo, ebo, gpu, gfx]
|
tags: [opengl, mesh, vao, vbo, ebo, gpu, gfx, mesh-3d]
|
||||||
uses_functions: ["gl_loader_cpp_gfx", "mesh_obj_load_cpp_gfx"]
|
uses_functions: ["gl_loader_cpp_gfx", "mesh_obj_load_cpp_gfx"]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
|||||||
purity: pure
|
purity: pure
|
||||||
signature: "Mesh mesh_obj_parse(const char* obj_text, size_t len); Mesh mesh_obj_load(const char* path)"
|
signature: "Mesh mesh_obj_parse(const char* obj_text, size_t len); Mesh mesh_obj_load(const char* path)"
|
||||||
description: "Parser minimal de Wavefront .obj — soporta v, vn, f (tris y quads). Genera normales por face si faltan. mesh_obj_parse es puro; mesh_obj_load es helper impuro que lee fichero y delega."
|
description: "Parser minimal de Wavefront .obj — soporta v, vn, f (tris y quads). Genera normales por face si faltan. mesh_obj_parse es puro; mesh_obj_load es helper impuro que lee fichero y delega."
|
||||||
tags: [obj, mesh, parser, wavefront, loader, geometry, 3d]
|
tags: [obj, mesh, parser, wavefront, loader, geometry, 3d, mesh-3d]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ GLuint compile_program() {
|
|||||||
void ensure_init(Cache& c) {
|
void ensure_init(Cache& c) {
|
||||||
if (c.initialized) return;
|
if (c.initialized) return;
|
||||||
fn::gfx::gl_loader_init();
|
fn::gfx::gl_loader_init();
|
||||||
fn::gfx::fb_init(c.fb);
|
fn::gfx::fb_init_depth(c.fb);
|
||||||
c.program = compile_program();
|
c.program = compile_program();
|
||||||
if (c.program) {
|
if (c.program) {
|
||||||
c.loc_view = glGetUniformLocation(c.program, "u_view");
|
c.loc_view = glGetUniformLocation(c.program, "u_view");
|
||||||
@@ -145,10 +145,9 @@ void mesh_viewer(const char* id, const MeshViewerConfig& cfg) {
|
|||||||
glBindFramebuffer(GL_FRAMEBUFFER, c.fb.fbo);
|
glBindFramebuffer(GL_FRAMEBUFFER, c.fb.fbo);
|
||||||
glViewport(0, 0, w, h);
|
glViewport(0, 0, w, h);
|
||||||
glClearColor(0.10f, 0.10f, 0.13f, 1.0f);
|
glClearColor(0.10f, 0.10f, 0.13f, 1.0f);
|
||||||
glClear(GL_COLOR_BUFFER_BIT);
|
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||||
// No depth attachment in our FBO — fall back to back-to-front-ish via
|
glEnable(GL_DEPTH_TEST);
|
||||||
// GL_DEPTH_TEST off. For inspection meshes this is fine; documented.
|
glDepthFunc(GL_LESS);
|
||||||
glDisable(GL_DEPTH_TEST);
|
|
||||||
|
|
||||||
glUseProgram(c.program);
|
glUseProgram(c.program);
|
||||||
auto m = fn::core::orbit_camera_matrices(*cfg.cam);
|
auto m = fn::core::orbit_camera_matrices(*cfg.cam);
|
||||||
@@ -183,7 +182,7 @@ void mesh_viewer(const char* id, const MeshViewerConfig& cfg) {
|
|||||||
// Restore GL state.
|
// Restore GL state.
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prev_fbo);
|
glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prev_fbo);
|
||||||
glViewport(prev_vp[0], prev_vp[1], prev_vp[2], prev_vp[3]);
|
glViewport(prev_vp[0], prev_vp[1], prev_vp[2], prev_vp[3]);
|
||||||
if (prev_depth) glEnable(GL_DEPTH_TEST);
|
if (prev_depth) glEnable(GL_DEPTH_TEST); else glDisable(GL_DEPTH_TEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display.
|
// Display.
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: mesh_viewer
|
|||||||
kind: component
|
kind: component
|
||||||
lang: cpp
|
lang: cpp
|
||||||
domain: viz
|
domain: viz
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "void mesh_viewer(const char* id, const MeshViewerConfig& cfg)"
|
signature: "void mesh_viewer(const char* id, const MeshViewerConfig& cfg)"
|
||||||
description: "Renderiza un MeshGpu (3D) en un FBO interno cacheado por id, con orbit camera, iluminacion Lambert headlight, opcion wireframe. Drag/wheel del mouse mueven la camara."
|
description: "Renderiza un MeshGpu (3D) en un FBO interno cacheado por id, con orbit camera, iluminacion Lambert headlight, depth test correcto (GL_DEPTH_COMPONENT24), opcion wireframe. Drag/wheel del mouse mueven la camara."
|
||||||
tags: [imgui, opengl, mesh, 3d, viewer, viz, fbo, pendiente-usar]
|
tags: [imgui, opengl, mesh, 3d, viewer, viz, fbo, cpp-dashboard-viz]
|
||||||
uses_functions: ["gl_framebuffer_cpp_gfx", "gl_loader_cpp_gfx", "gl_shader_cpp_gfx", "mesh_gpu_cpp_gfx", "orbit_camera_cpp_core"]
|
uses_functions: ["gl_framebuffer_cpp_gfx", "gl_loader_cpp_gfx", "gl_shader_cpp_gfx", "mesh_gpu_cpp_gfx", "orbit_camera_cpp_core"]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -20,6 +20,11 @@ test_file_path: ""
|
|||||||
file_path: "cpp/functions/viz/mesh_viewer.cpp"
|
file_path: "cpp/functions/viz/mesh_viewer.cpp"
|
||||||
framework: imgui
|
framework: imgui
|
||||||
emits: ["camera_drag", "camera_zoom"]
|
emits: ["camera_drag", "camera_zoom"]
|
||||||
|
params:
|
||||||
|
- name: id
|
||||||
|
desc: "ID estable de ImGui para cachear el FBO y el programa shader. Cambiar el id entre frames acumula recursos (leak). Usar IDs constantes."
|
||||||
|
- name: cfg
|
||||||
|
desc: "MeshViewerConfig con mesh (MeshGpu*), cam (OrbitCamera*), size (ImVec2, -1 = full width), wireframe (bool), color (ImU32 RGBA)."
|
||||||
output: "Renderiza una imagen del mesh dentro del frame ImGui actual; muta cfg.cam in-place segun drag/wheel del mouse cuando el panel esta active/hovered."
|
output: "Renderiza una imagen del mesh dentro del frame ImGui actual; muta cfg.cam in-place segun drag/wheel del mouse cuando el panel esta active/hovered."
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,8 +33,8 @@ output: "Renderiza una imagen del mesh dentro del frame ImGui actual; muta cfg.c
|
|||||||
Componente de viz para inspeccionar geometria 3D dentro de cualquier panel ImGui. Internamente:
|
Componente de viz para inspeccionar geometria 3D dentro de cualquier panel ImGui. Internamente:
|
||||||
|
|
||||||
1. Compila/cachea (por `id`) un programa shader Lambert headlight (vertex + fragment).
|
1. Compila/cachea (por `id`) un programa shader Lambert headlight (vertex + fragment).
|
||||||
2. Cachea un `Framebuffer` por `id` y lo redimensiona segun `cfg.size`.
|
2. Cachea un `Framebuffer` con depth renderbuffer por `id` y lo redimensiona segun `cfg.size`.
|
||||||
3. Cada frame: bind FBO, draw `cfg.mesh`, mostrar la textura via `ImGui::Image`.
|
3. Cada frame: bind FBO, clear color+depth, draw `cfg.mesh` con depth test activo, mostrar la textura via `ImGui::Image`.
|
||||||
4. Si el panel esta active → llama `orbit_camera_handle_drag` con `MouseDelta`.
|
4. Si el panel esta active → llama `orbit_camera_handle_drag` con `MouseDelta`.
|
||||||
5. Si el panel esta hovered y hay scroll → ajusta zoom.
|
5. Si el panel esta hovered y hay scroll → ajusta zoom.
|
||||||
|
|
||||||
@@ -46,10 +51,23 @@ cfg.color = IM_COL32(160, 200, 255, 255);
|
|||||||
fn::viz::mesh_viewer("##teapot_view", cfg);
|
fn::viz::mesh_viewer("##teapot_view", cfg);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notas
|
## Cuando usarla
|
||||||
|
|
||||||
- **Sin depth buffer**: el FBO solo tiene attachment color (sigue el patron de `gl_framebuffer`). Para meshes complejos con auto-oclusion, esto produce artefactos. Issue futuro puede añadir depth/stencil renderbuffer.
|
Cuando necesites inspeccionar geometria 3D (OBJ, STL, cualquier MeshGpu) dentro de un panel ImGui existente, con orbit camera interactiva y auto-oclusion correcta de caras.
|
||||||
- **Wireframe**: usa `glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)` (no disponible en GL ES; protegido con `#ifndef __EMSCRIPTEN__`).
|
|
||||||
- **Cache por id**: si el `id` cambia dinamicamente entre frames, se acumulan FBOs y programas en memoria (leak). Usar IDs estables.
|
## Gotchas
|
||||||
|
|
||||||
|
- **Cache por id**: si el `id` cambia dinamicamente entre frames, se acumulan FBOs y programas en memoria (leak). Usar IDs estables (`"##nombre_fijo"`).
|
||||||
|
- **Wireframe**: usa `glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)` — no disponible en GL ES; protegido con `#ifndef __EMSCRIPTEN__`.
|
||||||
- **Iluminacion**: Lambert con luz fija en `+Z` view-space ("headlight"), suficiente para inspeccion. Sin specular, sin sombras.
|
- **Iluminacion**: Lambert con luz fija en `+Z` view-space ("headlight"), suficiente para inspeccion. Sin specular, sin sombras.
|
||||||
- **Matrices**: row-major desde `orbit_camera_matrices`; se suben con `transpose=GL_TRUE` (GL espera column-major).
|
- **Matrices**: row-major desde `orbit_camera_matrices`; se suben con `transpose=GL_TRUE` (GL espera column-major).
|
||||||
|
- **Estado GL**: salva y restaura `GL_FRAMEBUFFER_BINDING`, `GL_VIEWPORT` y `GL_DEPTH_TEST` antes/despues del render. No contamina el estado del frame ImGui principal.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- **Depth renderbuffer activo** (GL_DEPTH_COMPONENT24): auto-oclusion correcta en meshes solidos. `glEnable(GL_DEPTH_TEST)` + `glDepthFunc(GL_LESS)` dentro del render del FBO.
|
||||||
|
- Usa `fb_init_depth` de `gl_framebuffer_cpp_gfx` (v1.1.0+).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
v1.1.0 (2026-05-28) — depth renderbuffer via fb_init_depth, fix auto-oclusion en meshes solidos
|
||||||
|
|||||||
@@ -317,6 +317,13 @@ add_fn_test(test_sse_client test_sse_client.cpp
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/sse_client.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/sse_client.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp)
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp)
|
||||||
|
|
||||||
|
# --- gltf_load_mesh: GLB 2.0 parser puro (CPU, sin GL) ---
|
||||||
|
# Incluimos nlohmann desde cpp/vendor/. El parser no necesita GL ni imgui.
|
||||||
|
add_fn_test(test_gltf_load_mesh test_gltf_load_mesh.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/gfx/gltf_load_mesh.cpp)
|
||||||
|
target_include_directories(test_gltf_load_mesh PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../vendor)
|
||||||
|
|
||||||
# --- Issue 0132 — ansi_parser: logica pura, sin ImGui ---
|
# --- Issue 0132 — ansi_parser: logica pura, sin ImGui ---
|
||||||
add_fn_test(test_ansi_parser test_ansi_parser.cpp
|
add_fn_test(test_ansi_parser test_ansi_parser.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp)
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp)
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Unit tests para gltf_load_mesh (issue: gltf_load_mesh_cpp_gfx).
|
||||||
|
// Cubre: reject magic invalido, triangulo con POSITION+indices sin NORMAL
|
||||||
|
// (normales generadas correctamente), quad (2 tris), load desde memoria.
|
||||||
|
// No requiere contexto GL — logica CPU pura.
|
||||||
|
|
||||||
|
#define CATCH_CONFIG_MAIN
|
||||||
|
#include "catch_amalgamated.hpp"
|
||||||
|
|
||||||
|
#include "gfx/gltf_load_mesh.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GLB builder helpers (minimal — construye GLB en memoria para tests)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// nlohmann is in vendor/ but test includes functions/ and framework/ by default.
|
||||||
|
// Build a GLB manually via byte helpers to avoid adding another include path.
|
||||||
|
|
||||||
|
static void write_u32le(std::vector<uint8_t>& buf, uint32_t v) {
|
||||||
|
buf.push_back(v & 0xFF);
|
||||||
|
buf.push_back((v >> 8) & 0xFF);
|
||||||
|
buf.push_back((v >> 16) & 0xFF);
|
||||||
|
buf.push_back((v >> 24) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void write_f32le(std::vector<uint8_t>& buf, float v) {
|
||||||
|
uint32_t u; std::memcpy(&u, &v, 4);
|
||||||
|
write_u32le(buf, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Align 'buf' to 4-byte boundary by appending 0x20 (space) padding.
|
||||||
|
static void align4(std::vector<uint8_t>& buf) {
|
||||||
|
while (buf.size() % 4 != 0) buf.push_back(0x20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a minimal GLB with:
|
||||||
|
// positions: flat float xyz array (length = nv*3)
|
||||||
|
// indices: uint16 array (length = ni)
|
||||||
|
// normals: optional float xyz array (length = nv*3, nullptr = omit)
|
||||||
|
// Returns the complete GLB byte vector.
|
||||||
|
static std::vector<uint8_t> build_glb(const float* positions, size_t nv,
|
||||||
|
const uint16_t* indices, size_t ni,
|
||||||
|
const float* normals = nullptr) {
|
||||||
|
// BIN chunk: positions | indices | (normals)
|
||||||
|
std::vector<uint8_t> bin;
|
||||||
|
size_t pos_offset = 0;
|
||||||
|
size_t pos_byteLen = nv * 3 * 4;
|
||||||
|
for (size_t i = 0; i < nv*3; ++i) write_f32le(bin, positions[i]);
|
||||||
|
|
||||||
|
// pad before indices so they start at 4-byte alignment
|
||||||
|
while (bin.size() % 4 != 0) bin.push_back(0);
|
||||||
|
size_t idx_offset = bin.size();
|
||||||
|
size_t idx_byteLen = ni * 2;
|
||||||
|
for (size_t i = 0; i < ni; ++i) {
|
||||||
|
bin.push_back(indices[i] & 0xFF);
|
||||||
|
bin.push_back((indices[i] >> 8) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t nrm_offset = 0, nrm_byteLen = 0;
|
||||||
|
if (normals) {
|
||||||
|
while (bin.size() % 4 != 0) bin.push_back(0);
|
||||||
|
nrm_offset = bin.size();
|
||||||
|
nrm_byteLen = nv * 3 * 4;
|
||||||
|
for (size_t i = 0; i < nv*3; ++i) write_f32le(bin, normals[i]);
|
||||||
|
}
|
||||||
|
// GLB chunk length must be multiple of 4
|
||||||
|
while (bin.size() % 4 != 0) bin.push_back(0);
|
||||||
|
|
||||||
|
// Build JSON
|
||||||
|
// accessor 0: POSITION (vec3 float, bufferView 0)
|
||||||
|
// accessor 1: indices (scalar uint16, bufferView 1)
|
||||||
|
// accessor 2: NORMAL (vec3 float, bufferView 2) — if normals present
|
||||||
|
std::string json = "{";
|
||||||
|
json += "\"asset\":{\"version\":\"2.0\"},";
|
||||||
|
json += "\"buffers\":[{\"byteLength\":" + std::to_string(bin.size()) + "}],";
|
||||||
|
|
||||||
|
// bufferViews
|
||||||
|
json += "\"bufferViews\":[";
|
||||||
|
json += "{\"buffer\":0,\"byteOffset\":" + std::to_string(pos_offset) +
|
||||||
|
",\"byteLength\":" + std::to_string(pos_byteLen) + "}";
|
||||||
|
json += ",{\"buffer\":0,\"byteOffset\":" + std::to_string(idx_offset) +
|
||||||
|
",\"byteLength\":" + std::to_string(idx_byteLen) + "}";
|
||||||
|
if (normals) {
|
||||||
|
json += ",{\"buffer\":0,\"byteOffset\":" + std::to_string(nrm_offset) +
|
||||||
|
",\"byteLength\":" + std::to_string(nrm_byteLen) + "}";
|
||||||
|
}
|
||||||
|
json += "],";
|
||||||
|
|
||||||
|
// accessors
|
||||||
|
json += "\"accessors\":[";
|
||||||
|
json += "{\"bufferView\":0,\"byteOffset\":0,\"componentType\":5126,\"count\":" +
|
||||||
|
std::to_string(nv) + ",\"type\":\"VEC3\"}";
|
||||||
|
json += ",{\"bufferView\":1,\"byteOffset\":0,\"componentType\":5123,\"count\":" +
|
||||||
|
std::to_string(ni) + ",\"type\":\"SCALAR\"}";
|
||||||
|
if (normals) {
|
||||||
|
json += ",{\"bufferView\":2,\"byteOffset\":0,\"componentType\":5126,\"count\":" +
|
||||||
|
std::to_string(nv) + ",\"type\":\"VEC3\"}";
|
||||||
|
}
|
||||||
|
json += "],";
|
||||||
|
|
||||||
|
// meshes / primitives
|
||||||
|
std::string attrs = "\"POSITION\":0";
|
||||||
|
if (normals) attrs += ",\"NORMAL\":2";
|
||||||
|
json += "\"meshes\":[{\"primitives\":[{\"attributes\":{" + attrs + "},\"indices\":1}]}]";
|
||||||
|
json += "}";
|
||||||
|
|
||||||
|
// Pad JSON to 4-byte boundary
|
||||||
|
while (json.size() % 4 != 0) json += ' ';
|
||||||
|
|
||||||
|
// Assemble GLB
|
||||||
|
std::vector<uint8_t> glb;
|
||||||
|
uint32_t json_chunk_len = (uint32_t)json.size();
|
||||||
|
uint32_t bin_chunk_len = (uint32_t)bin.size();
|
||||||
|
uint32_t total = 12 + 8 + json_chunk_len + 8 + bin_chunk_len;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
write_u32le(glb, 0x46546C67u); // magic "glTF"
|
||||||
|
write_u32le(glb, 2u); // version
|
||||||
|
write_u32le(glb, total);
|
||||||
|
|
||||||
|
// Chunk 0: JSON
|
||||||
|
write_u32le(glb, json_chunk_len);
|
||||||
|
write_u32le(glb, 0x4E4F534Au); // "JSON"
|
||||||
|
for (char c : json) glb.push_back((uint8_t)c);
|
||||||
|
|
||||||
|
// Chunk 1: BIN
|
||||||
|
write_u32le(glb, bin_chunk_len);
|
||||||
|
write_u32le(glb, 0x004E4942u); // "BIN\0"
|
||||||
|
for (uint8_t b : bin) glb.push_back(b);
|
||||||
|
|
||||||
|
return glb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("invalid magic -> empty Mesh + last_error set", "[gltf][reject]") {
|
||||||
|
std::vector<uint8_t> bad(12, 0);
|
||||||
|
bad[0] = 0xDE; bad[1] = 0xAD; bad[2] = 0xBE; bad[3] = 0xEF;
|
||||||
|
// version=2, total=12
|
||||||
|
bad[4]=2; bad[8]=12;
|
||||||
|
|
||||||
|
auto m = fn::gfx::gltf_load_mesh_from_memory(bad.data(), bad.size());
|
||||||
|
REQUIRE(m.positions.empty());
|
||||||
|
REQUIRE(m.indices.empty());
|
||||||
|
std::string err = fn::gfx::gltf_load_last_error();
|
||||||
|
REQUIRE(!err.empty());
|
||||||
|
INFO("last_error: " << err);
|
||||||
|
// Should mention magic or "not a GLB"
|
||||||
|
REQUIRE((err.find("magic") != std::string::npos ||
|
||||||
|
err.find("GLB") != std::string::npos));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("too-small buffer -> empty Mesh + last_error set", "[gltf][reject]") {
|
||||||
|
std::vector<uint8_t> tiny = {0x67, 0x6C, 0x54, 0x46}; // only 4 bytes
|
||||||
|
auto m = fn::gfx::gltf_load_mesh_from_memory(tiny.data(), tiny.size());
|
||||||
|
REQUIRE(m.positions.empty());
|
||||||
|
std::string err = fn::gfx::gltf_load_last_error();
|
||||||
|
REQUIRE(!err.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("triangle without NORMAL -> normals generated, correct count", "[gltf][triangle][normals]") {
|
||||||
|
// One triangle in XY plane (z=0): (0,0,0), (1,0,0), (0,1,0)
|
||||||
|
// Face normal = (0,0,1) → all vertices should get approx (0,0,1)
|
||||||
|
float pos[] = { 0,0,0, 1,0,0, 0,1,0 };
|
||||||
|
uint16_t idx[] = { 0, 1, 2 };
|
||||||
|
|
||||||
|
auto glb = build_glb(pos, 3, idx, 3, /*normals=*/nullptr);
|
||||||
|
auto m = fn::gfx::gltf_load_mesh_from_memory(glb.data(), glb.size());
|
||||||
|
|
||||||
|
REQUIRE(m.positions.size() == 9); // 3 vertices * 3 floats
|
||||||
|
REQUIRE(m.indices.size() == 3);
|
||||||
|
REQUIRE(m.normals.size() == m.positions.size());
|
||||||
|
|
||||||
|
// Check positions
|
||||||
|
REQUIRE(m.positions[0] == Catch::Approx(0.0f));
|
||||||
|
REQUIRE(m.positions[1] == Catch::Approx(0.0f));
|
||||||
|
REQUIRE(m.positions[2] == Catch::Approx(0.0f));
|
||||||
|
REQUIRE(m.positions[3] == Catch::Approx(1.0f));
|
||||||
|
REQUIRE(m.positions[6] == Catch::Approx(0.0f));
|
||||||
|
REQUIRE(m.positions[7] == Catch::Approx(1.0f));
|
||||||
|
|
||||||
|
// Check indices
|
||||||
|
REQUIRE(m.indices[0] == 0u);
|
||||||
|
REQUIRE(m.indices[1] == 1u);
|
||||||
|
REQUIRE(m.indices[2] == 2u);
|
||||||
|
|
||||||
|
// Generated normals should point toward +Z for all 3 vertices
|
||||||
|
for (int v = 0; v < 3; ++v) {
|
||||||
|
REQUIRE(m.normals[v*3+0] == Catch::Approx(0.0f).margin(1e-5f));
|
||||||
|
REQUIRE(m.normals[v*3+1] == Catch::Approx(0.0f).margin(1e-5f));
|
||||||
|
REQUIRE(m.normals[v*3+2] == Catch::Approx(1.0f).margin(1e-5f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("quad (2 triangles) -> positions.size()==12, indices.size()==6", "[gltf][quad]") {
|
||||||
|
// Quad in XY: (0,0,0),(1,0,0),(1,1,0),(0,1,0) split into 2 tris
|
||||||
|
float pos[] = { 0,0,0, 1,0,0, 1,1,0, 0,1,0 };
|
||||||
|
uint16_t idx[] = { 0,1,2, 0,2,3 };
|
||||||
|
|
||||||
|
auto glb = build_glb(pos, 4, idx, 6, nullptr);
|
||||||
|
auto m = fn::gfx::gltf_load_mesh_from_memory(glb.data(), glb.size());
|
||||||
|
|
||||||
|
REQUIRE(m.positions.size() == 12); // 4 * 3
|
||||||
|
REQUIRE(m.normals.size() == 12);
|
||||||
|
REQUIRE(m.indices.size() == 6);
|
||||||
|
|
||||||
|
// All normals should be (0,0,1) — flat XY plane
|
||||||
|
for (int v = 0; v < 4; ++v) {
|
||||||
|
REQUIRE(m.normals[v*3+2] == Catch::Approx(1.0f).margin(1e-5f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("explicit normals -> passed through unchanged", "[gltf][normals]") {
|
||||||
|
float pos[] = { 0,0,0, 1,0,0, 0,1,0 };
|
||||||
|
uint16_t idx[] = { 0, 1, 2 };
|
||||||
|
// Provide normals pointing in -Z (unusual, but should be respected)
|
||||||
|
float nrm[] = { 0,0,-1, 0,0,-1, 0,0,-1 };
|
||||||
|
|
||||||
|
auto glb = build_glb(pos, 3, idx, 3, nrm);
|
||||||
|
auto m = fn::gfx::gltf_load_mesh_from_memory(glb.data(), glb.size());
|
||||||
|
|
||||||
|
REQUIRE(m.positions.size() == 9);
|
||||||
|
REQUIRE(m.normals.size() == 9);
|
||||||
|
|
||||||
|
for (int v = 0; v < 3; ++v) {
|
||||||
|
REQUIRE(m.normals[v*3+0] == Catch::Approx(0.0f).margin(1e-5f));
|
||||||
|
REQUIRE(m.normals[v*3+1] == Catch::Approx(0.0f).margin(1e-5f));
|
||||||
|
REQUIRE(m.normals[v*3+2] == Catch::Approx(-1.0f).margin(1e-5f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("nonexistent file -> empty Mesh + last_error set", "[gltf][file]") {
|
||||||
|
auto m = fn::gfx::gltf_load_mesh_from_file("/tmp/does_not_exist_gltf_test_abc123.glb");
|
||||||
|
REQUIRE(m.positions.empty());
|
||||||
|
std::string err = fn::gfx::gltf_load_last_error();
|
||||||
|
REQUIRE(!err.empty());
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [kanban](kanban.md) | 5 | Parser/writer/scanner/watcher de dev/issues/ y dev/flows/: base del backend kanban_cpp v2 |
|
| [kanban](kanban.md) | 5 | Parser/writer/scanner/watcher de dev/issues/ y dev/flows/: base del backend kanban_cpp v2 |
|
||||||
| [wireguard](wireguard.md) | 7 | Instalar, configurar, operar y monitorizar mesh WireGuard hub-and-spoke: keygen, hub setup, peer add/revoke, status JSON |
|
| [wireguard](wireguard.md) | 7 | Instalar, configurar, operar y monitorizar mesh WireGuard hub-and-spoke: keygen, hub setup, peer add/revoke, status JSON |
|
||||||
| [matrix-mas](matrix-mas.md) | 5 | Migración Synapse→MAS: habilitar MSC3861, verificar login flows, parche .well-known, registro clientes OAuth2, syn2mas |
|
| [matrix-mas](matrix-mas.md) | 5 | Migración Synapse→MAS: habilitar MSC3861, verificar login flows, parche .well-known, registro clientes OAuth2, syn2mas |
|
||||||
|
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
|
||||||
|
|
||||||
## Como anadir grupo
|
## Como anadir grupo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
group: mesh-3d
|
||||||
|
description: "Carga, parse y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0). Flujo CPU→GPU para apps ImGui."
|
||||||
|
---
|
||||||
|
|
||||||
|
# mesh-3d — Capability Group
|
||||||
|
|
||||||
|
Cluster de funciones para cargar geometria 3D desde disco o memoria (CPU) y subirla a GPU con OpenGL. El flujo canonico es: **loader → `fn::gfx::Mesh` → `mesh_gpu_upload`**.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma corta | Que hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `gltf_load_mesh_cpp_gfx` | `Mesh gltf_load_mesh_from_file(path)` | Carga primer mesh de un GLB 2.0. Genera normales si faltan. |
|
||||||
|
| `mesh_obj_load_cpp_gfx` | `Mesh mesh_obj_load(path); Mesh mesh_obj_parse(buf, len)` | Carga/parsea Wavefront .obj. Genera normales por face si faltan. |
|
||||||
|
| `mesh_gpu_cpp_gfx` | `MeshGpu mesh_gpu_upload(mesh); void mesh_gpu_destroy(gpu)` | Sube `fn::gfx::Mesh` CPU a VAO+VBO+EBO OpenGL. |
|
||||||
|
|
||||||
|
## Struct compartido
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// cpp/functions/gfx/mesh_obj_load.h
|
||||||
|
namespace fn::gfx {
|
||||||
|
struct Mesh {
|
||||||
|
std::vector<float> positions; // xyz stride=3
|
||||||
|
std::vector<float> normals; // xyz stride=3, mismo length que positions
|
||||||
|
std::vector<uint32_t> indices; // tri-list, multiplo de 3
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejemplo canonico end-to-end
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "gfx/gltf_load_mesh.h" // gltf_load_mesh_from_file
|
||||||
|
#include "gfx/mesh_obj_load.h" // Mesh (struct)
|
||||||
|
#include "gfx/mesh_gpu.h" // mesh_gpu_upload, mesh_gpu_destroy
|
||||||
|
|
||||||
|
// -- Cargar desde GLB (ej. output de TripoSR/trimesh) --
|
||||||
|
auto cpu = fn::gfx::gltf_load_mesh_from_file("model.glb");
|
||||||
|
if (cpu.positions.empty()) {
|
||||||
|
fprintf(stderr, "load failed: %s\n", fn::gfx::gltf_load_last_error());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Alternativamente desde OBJ --
|
||||||
|
// auto cpu = fn::gfx::mesh_obj_load("model.obj");
|
||||||
|
|
||||||
|
// -- Subir a GPU (requiere contexto GL activo) --
|
||||||
|
auto gpu = fn::gfx::mesh_gpu_upload(cpu);
|
||||||
|
if (!gpu.ok()) return;
|
||||||
|
|
||||||
|
// -- Render --
|
||||||
|
glUseProgram(prog);
|
||||||
|
glBindVertexArray(gpu.vao);
|
||||||
|
glDrawElements(GL_TRIANGLES, gpu.index_count, GL_UNSIGNED_INT, 0);
|
||||||
|
glBindVertexArray(0);
|
||||||
|
|
||||||
|
// -- Cleanup --
|
||||||
|
fn::gfx::mesh_gpu_destroy(gpu);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shader esperado (layout)
|
||||||
|
|
||||||
|
```glsl
|
||||||
|
#version 330 core
|
||||||
|
layout(location = 0) in vec3 a_pos;
|
||||||
|
layout(location = 1) in vec3 a_normal;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- Sin texturas/UV — mesh viewer usa color uniforme o shading por normal.
|
||||||
|
- Sin animaciones ni skinning.
|
||||||
|
- `gltf_load_mesh`: solo GLB binario (no .gltf+.bin separado, no data-URI base64). Solo primer mesh/primitive.
|
||||||
|
- `mesh_obj_load`: solo tris y quads; N-gons ignorados.
|
||||||
|
- `mesh_gpu`: asume contexto OpenGL 3.3+ activo. Sin streaming (GL_STATIC_DRAW).
|
||||||
|
|
||||||
|
## Prerequisitos
|
||||||
|
|
||||||
|
- `mesh_gpu_upload` requiere contexto GL activo (llama a `glGenVertexArrays`, etc.).
|
||||||
|
- `gltf_load_mesh` requiere `cpp/vendor/nlohmann/json.hpp`.
|
||||||
|
- `mesh_obj_load` solo stdlib — sin dependencias extra.
|
||||||
@@ -43,7 +43,7 @@ uses_types:
|
|||||||
- ColorRule_cpp_core
|
- ColorRule_cpp_core
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
error_type: ""
|
error_type: "error_go_core"
|
||||||
imports:
|
imports:
|
||||||
- imgui.h
|
- imgui.h
|
||||||
- app_base.h
|
- app_base.h
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
name: clickhouse_insert_rows
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def clickhouse_insert_rows(base_url: str, table: str, rows: list[dict], *, user: str = 'default', password: str = '', database: str = 'analytics', timeout: float = 30.0) -> int"
|
||||||
|
description: "Inserta una lista de dicts en ClickHouse via HTTP (puerto 8123) usando el formato JSONEachRow. Retorna el numero de filas enviadas."
|
||||||
|
tags: [clickhouse, analytics, http, insert, ingest, etl]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [json, urllib.request, urllib.parse, urllib.error]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/infra/clickhouse_insert_rows.py"
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor ClickHouse sin trailing slash. Ej: 'http://127.0.0.1:18123'. Para tunel SSH, apunta al puerto local reenviado."
|
||||||
|
- name: table
|
||||||
|
desc: "Nombre de la tabla destino, con o sin prefijo de base de datos. Ej: 'analytics.gnula_movies' o 'gnula_movies'."
|
||||||
|
- name: rows
|
||||||
|
desc: "Lista de dicts a insertar. Cada dict se serializa como una linea JSON. Las claves deben coincidir con columnas existentes; columnas ausentes usan DEFAULT."
|
||||||
|
- name: user
|
||||||
|
desc: "Usuario ClickHouse para autenticacion via header X-ClickHouse-User (default: 'default')."
|
||||||
|
- name: password
|
||||||
|
desc: "Contrasena ClickHouse para autenticacion via header X-ClickHouse-Key (default: cadena vacia)."
|
||||||
|
- name: database
|
||||||
|
desc: "Base de datos ClickHouse enviada como parametro de query (default: 'analytics')."
|
||||||
|
- name: timeout
|
||||||
|
desc: "Timeout de socket en segundos (default: 30.0)."
|
||||||
|
output: "Entero con el numero de filas insertadas (len(rows)). Retorna 0 si rows esta vacio sin contactar el servidor."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from infra import clickhouse_insert_rows
|
||||||
|
|
||||||
|
n = clickhouse_insert_rows(
|
||||||
|
"http://127.0.0.1:18123",
|
||||||
|
"analytics.gnula_movies",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"snapshot_ts": "2026-05-30 14:00:00",
|
||||||
|
"href": "/pelicula/avatar-el-camino-del-agua",
|
||||||
|
"title": "Avatar: El camino del agua",
|
||||||
|
"year": 2022,
|
||||||
|
"flags": "es.png",
|
||||||
|
"lang_es": 1,
|
||||||
|
"status": "pending",
|
||||||
|
"in_library": 0,
|
||||||
|
"detected_at": "2026-05-30T14:00:00",
|
||||||
|
"downloaded_at": "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
user="analytics",
|
||||||
|
password="secret",
|
||||||
|
database="analytics",
|
||||||
|
)
|
||||||
|
print(f"Inserted {n} rows")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando un ETL empuja snapshots o eventos a ClickHouse via HTTP (puerto 8123), incluyendo a traves de un tunel SSH a un ClickHouse interno no expuesto publicamente. Alternativa ligera (solo stdlib) a `clickhouse-driver` o `clickhouse-connect` cuando no se quieren dependencias externas.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `base_url` sin trailing slash: `"http://127.0.0.1:18123"`, no `"http://127.0.0.1:18123/"`.
|
||||||
|
- Fechas y datetimes deben pasarse como strings en formato que ClickHouse acepte (`"YYYY-MM-DD HH:MM:SS"`) o como enteros epoch. El caller formatea; esta funcion no convierte tipos.
|
||||||
|
- Arrays van como listas JSON nativas Python: `{"tags": ["drama", "sci-fi"]}`.
|
||||||
|
- Columnas ausentes en un dict usan el valor DEFAULT de la tabla (JSONEachRow ignora claves faltantes). No falla.
|
||||||
|
- Para tunel SSH: `ssh -L 18123:localhost:8123 user@host` y usar `base_url="http://127.0.0.1:18123"`.
|
||||||
|
- En caso de error HTTP, `ValueError` incluye el codigo y los primeros 500 caracteres del cuerpo — util para depurar errores de schema o SQL malformado.
|
||||||
|
- Lotes grandes: no hay batching interno. Si `rows` tiene miles de elementos, el body puede ser grande. Partir en chunks desde el caller si es necesario.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Insert rows into ClickHouse via the HTTP interface (port 8123)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def clickhouse_insert_rows(
|
||||||
|
base_url: str,
|
||||||
|
table: str,
|
||||||
|
rows: list[dict],
|
||||||
|
*,
|
||||||
|
user: str = "default",
|
||||||
|
password: str = "",
|
||||||
|
database: str = "analytics",
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> int:
|
||||||
|
"""Insert a list of dicts into a ClickHouse table using JSONEachRow format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: ClickHouse HTTP base URL without trailing slash,
|
||||||
|
e.g. "http://127.0.0.1:18123".
|
||||||
|
table: Fully-qualified or bare table name, e.g. "analytics.gnula_movies".
|
||||||
|
rows: List of dicts to insert. Each dict becomes one JSON line.
|
||||||
|
user: ClickHouse username (default "default").
|
||||||
|
password: ClickHouse password (default empty string).
|
||||||
|
database: Target database sent as query param (default "analytics").
|
||||||
|
timeout: Socket timeout in seconds (default 30.0).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows inserted (len(rows)). Returns 0 if rows is empty
|
||||||
|
without contacting the server.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: On non-200 HTTP response, with status code and first
|
||||||
|
500 chars of the response body.
|
||||||
|
urllib.error.URLError: On network-level errors (connection refused,
|
||||||
|
DNS failure, timeout).
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
query = f"INSERT INTO {table} FORMAT JSONEachRow"
|
||||||
|
params = urllib.parse.urlencode({"database": database, "query": query})
|
||||||
|
url = f"{base_url}/?{params}"
|
||||||
|
|
||||||
|
body = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows)
|
||||||
|
body_bytes = body.encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=body_bytes,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
"X-ClickHouse-User": user,
|
||||||
|
"X-ClickHouse-Key": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
body_preview = resp.read(500).decode("utf-8", errors="replace")
|
||||||
|
raise ValueError(
|
||||||
|
f"ClickHouse insert failed: HTTP {resp.status} — {body_preview}"
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body_preview = exc.read(500).decode("utf-8", errors="replace")
|
||||||
|
raise ValueError(
|
||||||
|
f"ClickHouse insert failed: HTTP {exc.code} — {body_preview}"
|
||||||
|
) from exc
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: clickhouse_query
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def clickhouse_query(base_url: str, sql: str, *, user: str = 'default', password: str = '', database: str = 'analytics', timeout: float = 30.0) -> list[dict]"
|
||||||
|
description: "Ejecuta un SQL contra ClickHouse via HTTP (puerto 8123) y retorna los resultados como lista de dicts. Para SELECT usa JSONEachRow automaticamente; para DDL/DML retorna []."
|
||||||
|
tags: [clickhouse, analytics, http, query, select, read]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [json, urllib.request, urllib.parse, urllib.error]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/infra/clickhouse_query.py"
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor ClickHouse sin trailing slash. Ej: 'http://127.0.0.1:18123'. Para tunel SSH, apunta al puerto local reenviado."
|
||||||
|
- name: sql
|
||||||
|
desc: "SQL completo a ejecutar. El caller escribe el SQL entero; esta funcion no anade nada. Para SELECT retorna filas; para CREATE/INSERT/etc. retorna []."
|
||||||
|
- name: user
|
||||||
|
desc: "Usuario ClickHouse para autenticacion via header X-ClickHouse-User (default: 'default')."
|
||||||
|
- name: password
|
||||||
|
desc: "Contrasena ClickHouse para autenticacion via header X-ClickHouse-Key (default: cadena vacia)."
|
||||||
|
- name: database
|
||||||
|
desc: "Base de datos ClickHouse enviada como parametro de query (default: 'analytics')."
|
||||||
|
- name: timeout
|
||||||
|
desc: "Timeout de socket en segundos (default: 30.0)."
|
||||||
|
output: "Lista de dicts, uno por fila del resultado. Lista vacia para sentencias sin resultado (DDL, INSERT). Los numeros Int64 de ClickHouse llegan como strings JSON — castear con int() si se necesita aritmetica."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from infra import clickhouse_query
|
||||||
|
|
||||||
|
# Contar filas
|
||||||
|
rows = clickhouse_query(
|
||||||
|
"http://127.0.0.1:18123",
|
||||||
|
"SELECT count() AS c FROM analytics.gnula_movies",
|
||||||
|
user="analytics",
|
||||||
|
password="secret",
|
||||||
|
database="analytics",
|
||||||
|
)
|
||||||
|
print(int(rows[0]["c"])) # Int64 llega como string → castear
|
||||||
|
|
||||||
|
# Leer ultimas inserciones
|
||||||
|
recent = clickhouse_query(
|
||||||
|
"http://127.0.0.1:18123",
|
||||||
|
"SELECT snapshot_ts, title, status FROM analytics.gnula_movies ORDER BY snapshot_ts DESC LIMIT 5",
|
||||||
|
user="analytics",
|
||||||
|
password="secret",
|
||||||
|
)
|
||||||
|
for r in recent:
|
||||||
|
print(r["snapshot_ts"], r["title"], r["status"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Leer agregados, validar ingest o inspeccionar datos en ClickHouse via HTTP sin dependencias externas. Util en ETLs de validacion, notebooks, scripts de monitoreo y pipelines que ya usan `clickhouse_insert_rows_py_infra` para escribir y necesitan verificar el resultado. Tambien sirve para ejecutar DDL (CREATE TABLE, etc.) cuando la respuesta vacia es esperada.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `base_url` sin trailing slash: `"http://127.0.0.1:18123"`, no `"http://127.0.0.1:18123/"`.
|
||||||
|
- El caller escribe el SQL completo. Esta funcion no anade FORMAT, LIMIT ni nada — lo que se pasa es lo que se ejecuta.
|
||||||
|
- **Int64 y UInt64 de ClickHouse llegan como strings JSON** en JSONEachRow. Castear explicitamente: `int(row["c"])`. Float64 llega como numero JSON nativo.
|
||||||
|
- Para SELECT sin FORMAT explicito, `default_format=JSONEachRow` se aplica automaticamente via query param. No hace falta escribir `FORMAT JSONEachRow` en el SQL.
|
||||||
|
- DDL y DML (CREATE, INSERT, ALTER) retornan cuerpo vacio → la funcion retorna `[]`. No es un error.
|
||||||
|
- Para tunel SSH: `ssh -L 18123:localhost:8123 user@host` y usar `base_url="http://127.0.0.1:18123"`.
|
||||||
|
- En caso de error HTTP, `ValueError` incluye el codigo y los primeros 500 caracteres del cuerpo — util para depurar errores de SQL o permisos.
|
||||||
|
- Queries con resultado muy grande se leen enteras en memoria. Para resultados masivos, usar LIMIT o streaming manual con `urllib.request.urlopen` directamente.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Query ClickHouse via the HTTP interface (port 8123) and return rows as dicts."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def clickhouse_query(
|
||||||
|
base_url: str,
|
||||||
|
sql: str,
|
||||||
|
*,
|
||||||
|
user: str = "default",
|
||||||
|
password: str = "",
|
||||||
|
database: str = "analytics",
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Execute a SQL statement against ClickHouse via HTTP and return results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: ClickHouse HTTP base URL without trailing slash,
|
||||||
|
e.g. "http://127.0.0.1:18123".
|
||||||
|
sql: Full SQL statement. For SELECT queries the server returns
|
||||||
|
JSONEachRow automatically via the default_format param.
|
||||||
|
For DDL/DML with no result set (CREATE, INSERT, etc.)
|
||||||
|
the response body is empty and [] is returned.
|
||||||
|
user: ClickHouse username (default "default").
|
||||||
|
password: ClickHouse password (default empty string).
|
||||||
|
database: Target database sent as query param (default "analytics").
|
||||||
|
timeout: Socket timeout in seconds (default 30.0).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts, one per result row. Empty list for statements that
|
||||||
|
produce no result set. Numbers may come back as strings for some
|
||||||
|
ClickHouse types (e.g. Int64 is returned as a JSON string in
|
||||||
|
JSONEachRow — cast explicitly if needed: int(row["c"])).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: On non-200 HTTP response, with status code and first
|
||||||
|
500 chars of the response body.
|
||||||
|
urllib.error.URLError: On network-level errors (connection refused,
|
||||||
|
DNS failure, timeout).
|
||||||
|
"""
|
||||||
|
params = urllib.parse.urlencode(
|
||||||
|
{"database": database, "default_format": "JSONEachRow"}
|
||||||
|
)
|
||||||
|
url = f"{base_url}/?{params}"
|
||||||
|
|
||||||
|
body_bytes = sql.encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=body_bytes,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
"X-ClickHouse-User": user,
|
||||||
|
"X-ClickHouse-Key": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
body_preview = resp.read(500).decode("utf-8", errors="replace")
|
||||||
|
raise ValueError(
|
||||||
|
f"ClickHouse query failed: HTTP {resp.status} — {body_preview}"
|
||||||
|
)
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body_preview = exc.read(500).decode("utf-8", errors="replace")
|
||||||
|
raise ValueError(
|
||||||
|
f"ClickHouse query failed: HTTP {exc.code} — {body_preview}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: popelis_create_user
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def popelis_create_user(base_url: str, admin_token: str, username: str, password: str, timeout: float = 30.0) -> dict"
|
||||||
|
description: "Crea un usuario en la API de administracion de Popelis (POST /api/admin/users). El backend crea automaticamente un usuario Jellyfin espejo (Modelo B). Registro CERRADO: requiere cabecera X-Admin-Token."
|
||||||
|
tags: [popelis, http, admin, user, jellyfin, infra]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servicio sin trailing slash. Ej: https://popelis.datardos.com"
|
||||||
|
- name: admin_token
|
||||||
|
desc: "Token de administracion. Se envia como cabecera X-Admin-Token. No logear ni exponer."
|
||||||
|
- name: username
|
||||||
|
desc: "Nombre de usuario a crear. Debe ser unico en el sistema."
|
||||||
|
- name: password
|
||||||
|
desc: "Contrasena inicial del nuevo usuario."
|
||||||
|
- name: timeout
|
||||||
|
desc: "Timeout en segundos para la peticion HTTP. Default 30.0."
|
||||||
|
output: "Dict con los datos del usuario creado: {id: N, username: str, jfUserId: str}"
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["json", "urllib.request", "urllib.error"]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/infra/popelis_create_user.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from infra.popelis_create_user import popelis_create_user
|
||||||
|
|
||||||
|
result = popelis_create_user(
|
||||||
|
base_url="https://popelis.datardos.com",
|
||||||
|
admin_token="<admin-token>",
|
||||||
|
username="alice",
|
||||||
|
password="s3cur3pass",
|
||||||
|
)
|
||||||
|
# result == {"id": 42, "username": "alice", "jfUserId": "abc123-..."}
|
||||||
|
print(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites dar de alta un usuario nuevo en Popelis desde un script de administracion, pipeline de onboarding o agente. Usar ANTES de `popelis_set_password` (que requiere que el usuario ya exista).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Token sensible**: `admin_token` es un secreto. Nunca logear el valor, interpolarlo en URLs ni persistirlo en texto plano.
|
||||||
|
- **Registro cerrado**: el endpoint rechaza cualquier peticion sin `X-Admin-Token` valido (HTTP 401/403).
|
||||||
|
- **409 si ya existe**: si el `username` ya esta registrado, el servidor devuelve HTTP 409 y la funcion lanza `ValueError`. Para idempotencia, captura el error y comprueba `"409"` en el mensaje o verifica existencia previa.
|
||||||
|
- **Jellyfin espejo**: el backend crea automaticamente un usuario Jellyfin con el mismo `username`. Si Jellyfin no esta disponible en el momento de la llamada, la creacion puede fallar en el backend (el error llega como 5xx).
|
||||||
|
- **Solo stdlib**: no requiere `requests` ni dependencias externas — usa `urllib.request`.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Crea un usuario en la API de administracion de Popelis."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def popelis_create_user(
|
||||||
|
base_url: str,
|
||||||
|
admin_token: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Crea un usuario en Popelis via la API de administracion.
|
||||||
|
|
||||||
|
Hace POST a {base_url}/api/admin/users con el token de admin y las
|
||||||
|
credenciales del nuevo usuario. El backend crea ademas automaticamente
|
||||||
|
un usuario Jellyfin espejo (Modelo B).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servicio, sin trailing slash.
|
||||||
|
Ej: "https://popelis.datardos.com"
|
||||||
|
admin_token: Token de administracion (cabecera X-Admin-Token).
|
||||||
|
Mantenerlo en secreto — no logear.
|
||||||
|
username: Nombre de usuario a crear. Debe ser unico.
|
||||||
|
password: Contrasena inicial del usuario.
|
||||||
|
timeout: Timeout en segundos para la peticion HTTP. Default 30.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con los datos del usuario creado:
|
||||||
|
{"id": N, "username": "...", "jfUserId": "..."}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si el servidor devuelve 4xx o 5xx. El mensaje incluye
|
||||||
|
el status code y el campo "error" del body JSON si existe.
|
||||||
|
urllib.error.URLError: Si no se puede conectar al servidor.
|
||||||
|
"""
|
||||||
|
url = f"{base_url.rstrip('/')}/api/admin/users"
|
||||||
|
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Admin-Token": admin_token,
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
body = resp.read()
|
||||||
|
return json.loads(body)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read()
|
||||||
|
try:
|
||||||
|
detail = json.loads(raw).get("error", raw.decode("utf-8", errors="replace"))
|
||||||
|
except Exception:
|
||||||
|
detail = raw.decode("utf-8", errors="replace")
|
||||||
|
raise ValueError(
|
||||||
|
f"popelis_create_user: HTTP {exc.code} — {detail}"
|
||||||
|
) from exc
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
name: popelis_import_media_drop
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: 1.0.0
|
||||||
|
purity: impure
|
||||||
|
description: "Importa drops de Popelis (manual/movies, manual/tv) via Radarr/Sonarr: identifica contra TMDb/TVDB, da de alta peli/serie con metadata y mueve+renombra a la libreria limpia que ve Jellyfin."
|
||||||
|
signature: "def popelis_import_media_drop(radarr_url: str, radarr_key: str, sonarr_url: str, sonarr_key: str, movies_drop: str = '/data/manual/movies', tv_drop: str = '/data/manual/tv', movie_root: str = '/data/media/movies', tv_root: str = '/data/media/tv', quality_profile_id: int = 4, import_mode: str = 'move', dry_run: bool = False, series_refresh_wait: float = 6.0, timeout: float = 60.0) -> dict"
|
||||||
|
error_type: error_go_core
|
||||||
|
returns_optional: false
|
||||||
|
tags: [popelis, radarr, sonarr, jellyfin, media, import, mediastack, http, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
imports: [json, re, time, urllib]
|
||||||
|
tested: false
|
||||||
|
file_path: python/functions/infra/popelis_import_media_drop.py
|
||||||
|
params:
|
||||||
|
- name: radarr_url
|
||||||
|
desc: "URL base de Radarr sin trailing slash. Ej http://localhost:7878"
|
||||||
|
- name: radarr_key
|
||||||
|
desc: "API key de Radarr (Settings > General > Security). Secreto."
|
||||||
|
- name: sonarr_url
|
||||||
|
desc: "URL base de Sonarr. Ej http://localhost:8989"
|
||||||
|
- name: sonarr_key
|
||||||
|
desc: "API key de Sonarr. Secreto."
|
||||||
|
- name: movies_drop
|
||||||
|
desc: "Carpeta drop de pelis EN EL NAMESPACE de Radarr (/data/manual/movies)."
|
||||||
|
- name: tv_drop
|
||||||
|
desc: "Carpeta drop de series EN EL NAMESPACE de Sonarr (/data/manual/tv)."
|
||||||
|
- name: movie_root
|
||||||
|
desc: "Root folder de Radarr donde se mueven las pelis (/data/media/movies)."
|
||||||
|
- name: tv_root
|
||||||
|
desc: "Root folder de Sonarr donde se mueven las series (/data/media/tv)."
|
||||||
|
- name: quality_profile_id
|
||||||
|
desc: "Quality profile id para altas nuevas. 4 = HD-1080p en el stack Popelis."
|
||||||
|
- name: import_mode
|
||||||
|
desc: "'move' (default, borra el origen) o 'copy'."
|
||||||
|
- name: dry_run
|
||||||
|
desc: "Si True no escribe nada: solo reporta que haria. Default False."
|
||||||
|
- name: series_refresh_wait
|
||||||
|
desc: "Segundos a esperar tras dar de alta una serie antes de re-listar el drop (Sonarr refresca episodios async). Default 6.0."
|
||||||
|
- name: timeout
|
||||||
|
desc: "Timeout HTTP por peticion en segundos. Default 60.0."
|
||||||
|
output: "Dict {movies: [...], tv: [...], summary: {...}}: una entrada por fichero/serie con su status (import_queued | added | no_match | still_unmatched | would_*) y contadores agregados en summary."
|
||||||
|
---
|
||||||
|
|
||||||
|
Importa una carpeta drop del stack Popelis (Radarr/Sonarr/Jellyfin) anadiendo
|
||||||
|
metadata. El usuario suelta ficheros sueltos en `manual/movies` o `manual/tv`;
|
||||||
|
esta funcion los identifica contra TMDb/TVDB, da de alta la pelicula/serie con
|
||||||
|
su ficha (poster, fanart, sinopsis) y dispara un ManualImport que mueve+renombra
|
||||||
|
el fichero a la libreria limpia (`media/movies`, `media/tv`). Radarr/Sonarr son
|
||||||
|
la unica fuente de verdad de metadata; Jellyfin solo escanea `media/` y nunca
|
||||||
|
mete fichas fantasma de descargas a medias.
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from infra.popelis_import_media_drop import popelis_import_media_drop
|
||||||
|
|
||||||
|
# Dry-run primero (no escribe nada)
|
||||||
|
res = popelis_import_media_drop(
|
||||||
|
radarr_url="http://localhost:7878",
|
||||||
|
radarr_key="63fb51c8c95746e2a327740baac02f5e",
|
||||||
|
sonarr_url="http://localhost:8989",
|
||||||
|
sonarr_key="1c6f380b1cca49b8b1223570e80f0071",
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
print(res["summary"])
|
||||||
|
# {'movies_queued': 1, 'movies_unmatched': 0, 'tv_queued': 0,
|
||||||
|
# 'tv_added_series': 1, 'tv_unmatched': 0, 'dry_run': True}
|
||||||
|
|
||||||
|
# Aplicar de verdad (mueve ficheros + descarga metadata)
|
||||||
|
res = popelis_import_media_drop(
|
||||||
|
radarr_url="http://localhost:7878", radarr_key="...",
|
||||||
|
sonarr_url="http://localhost:8989", sonarr_key="...",
|
||||||
|
dry_run=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI directo (lee keys de env):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RADARR_KEY=... SONARR_KEY=... \
|
||||||
|
python/.venv/bin/python3 python/functions/infra/popelis_import_media_drop.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando sueltas pelis/series sueltas en `F:/POPELIS/manual/{movies,tv}` y quieres
|
||||||
|
que Radarr/Sonarr las identifiquen, descarguen ficha y las muevan limpias a la
|
||||||
|
libreria que ve Jellyfin.
|
||||||
|
- Antes de mirar Jellyfin: pasa esta funcion para que la libreria solo contenga
|
||||||
|
media identificada (sin fichas fantasma).
|
||||||
|
- Lanzala on-demand tras cada drop. Para automatizar, envuelvela en un watcher/cron.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Paths en namespace de contenedor**: `movies_drop`/`tv_drop`/`movie_root`/`tv_root`
|
||||||
|
son paths DENTRO de Radarr/Sonarr (`/data/...`), NO del host. Radarr y Sonarr
|
||||||
|
montan `F:/POPELIS` en `/data`.
|
||||||
|
- **Permisos del drop (Windows bind)**: las carpetas drop deben ser escribibles
|
||||||
|
por el usuario del contenedor (PUID, abc/1000 en linuxserver). Si las creas como
|
||||||
|
root, `import_mode=move` falla con `UnauthorizedAccessException`. Fix:
|
||||||
|
`docker exec -u 0 radarr chown -R 1000:1000 /data/manual`.
|
||||||
|
- **Numeracion DVD vs aired**: releases en orden DVD (ej. Futurama S01 con 13 eps)
|
||||||
|
chocan con TVDB en orden emision (S01 = 9 eps). Los episodios fuera de rango
|
||||||
|
quedan `still_unmatched` con reason `Invalid season or episode` y permanecen en
|
||||||
|
el drop. No es un bug: requiere resolucion manual o cambiar el series type a
|
||||||
|
'DVD' en Sonarr.
|
||||||
|
- **Lookup coge el primer resultado**: titulos ambiguos pueden matchear mal.
|
||||||
|
Revisa el report antes de confiar; usa `dry_run=True` primero.
|
||||||
|
- **No renombra si la *arr no lo tiene activado**: el fichero se mueve a la carpeta
|
||||||
|
correcta pero conserva su nombre original salvo que actives "Rename" en la config
|
||||||
|
de naming de Radarr/Sonarr.
|
||||||
|
- **import_mode='move' borra el origen**: usa 'copy' si quieres conservar el drop.
|
||||||
|
- **Secretos**: `radarr_key`/`sonarr_key` son API keys. No logear ni interpolar en URLs.
|
||||||
|
- **Solo stdlib**: usa `urllib`, sin `requests`.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
(sin cambios desde v1.0.0)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Importa una carpeta drop de Popelis via Radarr/Sonarr anadiendo metadata.
|
||||||
|
|
||||||
|
Escanea las carpetas drop (manual/movies, manual/tv), identifica cada
|
||||||
|
fichero contra TMDb/TVDB usando los endpoints de lookup de Radarr/Sonarr,
|
||||||
|
da de alta la pelicula/serie si no existe (descargando metadata: poster,
|
||||||
|
fanart, fichas), y dispara un ManualImport que mueve+renombra el fichero a
|
||||||
|
la libreria limpia (media/movies, media/tv). Las *arr son la unica fuente
|
||||||
|
de verdad de metadata -> Jellyfin solo escanea media/ y nunca mete fichas
|
||||||
|
fantasma de descargas a medias.
|
||||||
|
|
||||||
|
Solo stdlib (urllib). Orquestacion HTTP pura sobre las APIs v3 de
|
||||||
|
Radarr/Sonarr; la funcion NO toca ficheros (lo hace la *arr en su propio
|
||||||
|
namespace de contenedor).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_VIDEO_RE = re.compile(r"\.(mkv|mp4|avi|m4v|mov|wmv|ts|mpg|mpeg)$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _req(method: str, url: str, key: str, body=None, timeout: float = 60.0):
|
||||||
|
"""HTTP request a una *arr con X-Api-Key. Lanza ValueError en 4xx/5xx."""
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={"X-Api-Key": key, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")[:400]
|
||||||
|
raise ValueError(f"{method} {url} -> HTTP {exc.code}: {detail}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _title_from_path(rel: str) -> str:
|
||||||
|
"""Deriva un termino de busqueda del path relativo de un drop.
|
||||||
|
|
||||||
|
Usa la carpeta raiz si existe (caso series: 'Futurama/Season 01/...'),
|
||||||
|
si no el nombre del fichero. Limpia separadores, sufijos SxxExx y el
|
||||||
|
anio + lo que venga detras para quedarse con el titulo limpio.
|
||||||
|
"""
|
||||||
|
seg = rel.split("/")[0] if "/" in rel else rel
|
||||||
|
base = _VIDEO_RE.sub("", seg)
|
||||||
|
base = re.sub(r"[._]", " ", base)
|
||||||
|
base = re.sub(r"\bS\d{1,2}E\d{1,2}.*$", "", base, flags=re.I).strip()
|
||||||
|
base = re.sub(r"\b(19|20)\d{2}\b.*$", "", base).strip()
|
||||||
|
return base or seg
|
||||||
|
|
||||||
|
|
||||||
|
def _import_radarr(url, key, drop, root, qpid, mode, dry, timeout):
|
||||||
|
enc = urllib.parse.quote(drop)
|
||||||
|
items = _req(
|
||||||
|
"GET",
|
||||||
|
f"{url}/api/v3/manualimport?folder={enc}&filterExistingFiles=true",
|
||||||
|
key, timeout=timeout,
|
||||||
|
) or []
|
||||||
|
existing = {m["tmdbId"]: m for m in _req("GET", f"{url}/api/v3/movie", key, timeout=timeout)}
|
||||||
|
out, files = [], []
|
||||||
|
for it in items:
|
||||||
|
movie = it.get("movie")
|
||||||
|
if not movie:
|
||||||
|
term = _title_from_path(it["relativePath"])
|
||||||
|
look = _req("GET", f"{url}/api/v3/movie/lookup?term={urllib.parse.quote(term)}", key, timeout=timeout)
|
||||||
|
if not look:
|
||||||
|
out.append({"file": it["relativePath"], "status": "no_match", "term": term})
|
||||||
|
continue
|
||||||
|
cand = look[0]
|
||||||
|
if cand["tmdbId"] in existing:
|
||||||
|
movie = existing[cand["tmdbId"]]
|
||||||
|
elif dry:
|
||||||
|
out.append({"file": it["relativePath"], "status": "would_add+import",
|
||||||
|
"match": f'{cand["title"]} ({cand.get("year")})'})
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
cand.update({
|
||||||
|
"qualityProfileId": qpid, "rootFolderPath": root,
|
||||||
|
"monitored": True, "minimumAvailability": "released",
|
||||||
|
"addOptions": {"searchForMovie": False},
|
||||||
|
})
|
||||||
|
movie = _req("POST", f"{url}/api/v3/movie", key, cand, timeout=timeout)
|
||||||
|
existing[movie["tmdbId"]] = movie
|
||||||
|
if dry:
|
||||||
|
out.append({"file": it["relativePath"], "status": "would_import",
|
||||||
|
"movie": f'{movie["title"]} ({movie.get("year")})'})
|
||||||
|
continue
|
||||||
|
files.append({
|
||||||
|
"path": it["path"], "movieId": movie["id"], "quality": it["quality"],
|
||||||
|
"languages": it.get("languages") or [{"id": 1, "name": "English"}],
|
||||||
|
})
|
||||||
|
out.append({"file": it["relativePath"], "status": "import_queued",
|
||||||
|
"movie": f'{movie["title"]} ({movie.get("year")})', "movieId": movie["id"]})
|
||||||
|
if files and not dry:
|
||||||
|
cmd = _req("POST", f"{url}/api/v3/command", key,
|
||||||
|
{"name": "ManualImport", "importMode": mode, "files": files}, timeout=timeout)
|
||||||
|
for o in out:
|
||||||
|
if o["status"] == "import_queued":
|
||||||
|
o["commandId"] = cmd["id"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _import_sonarr(url, key, drop, root, qpid, mode, dry, refresh_wait, timeout):
|
||||||
|
enc = urllib.parse.quote(drop)
|
||||||
|
items = _req("GET", f"{url}/api/v3/manualimport?folder={enc}&filterExistingFiles=true", key, timeout=timeout) or []
|
||||||
|
existing = {s["tvdbId"]: s for s in _req("GET", f"{url}/api/v3/series", key, timeout=timeout)}
|
||||||
|
out = []
|
||||||
|
# 1. dar de alta series desconocidas
|
||||||
|
unknown = {}
|
||||||
|
for it in items:
|
||||||
|
if not it.get("series"):
|
||||||
|
unknown.setdefault(_title_from_path(it["relativePath"]), True)
|
||||||
|
added_any = False
|
||||||
|
for term in unknown:
|
||||||
|
look = _req("GET", f"{url}/api/v3/series/lookup?term={urllib.parse.quote(term)}", key, timeout=timeout)
|
||||||
|
if not look:
|
||||||
|
out.append({"series_term": term, "status": "no_match"})
|
||||||
|
continue
|
||||||
|
cand = look[0]
|
||||||
|
if cand["tvdbId"] in existing:
|
||||||
|
continue
|
||||||
|
if dry:
|
||||||
|
out.append({"series_term": term, "status": "would_add",
|
||||||
|
"match": f'{cand["title"]} ({cand.get("year")})'})
|
||||||
|
continue
|
||||||
|
for s in cand.get("seasons", []):
|
||||||
|
s["monitored"] = True
|
||||||
|
cand.update({
|
||||||
|
"qualityProfileId": qpid, "rootFolderPath": root, "monitored": True,
|
||||||
|
"addOptions": {"searchForMissingEpisodes": False, "searchForCutoffUnmetEpisodes": False},
|
||||||
|
})
|
||||||
|
srv = _req("POST", f"{url}/api/v3/series", key, cand, timeout=timeout)
|
||||||
|
existing[srv["tvdbId"]] = srv
|
||||||
|
added_any = True
|
||||||
|
out.append({"series": f'{srv["title"]} ({srv.get("year")})', "status": "added", "seriesId": srv["id"]})
|
||||||
|
if dry:
|
||||||
|
for it in items:
|
||||||
|
out.append({"file": it["relativePath"], "status": "would_import_after_add"})
|
||||||
|
return out
|
||||||
|
if added_any:
|
||||||
|
time.sleep(refresh_wait) # esperar a que Sonarr refresque la ficha de episodios
|
||||||
|
# 2. re-listar: ahora los episodios estan matcheados
|
||||||
|
items = _req("GET", f"{url}/api/v3/manualimport?folder={enc}&filterExistingFiles=true", key, timeout=timeout) or []
|
||||||
|
files = []
|
||||||
|
for it in items:
|
||||||
|
eps = it.get("episodes") or []
|
||||||
|
ser = it.get("series")
|
||||||
|
if not ser or not eps:
|
||||||
|
out.append({"file": it["relativePath"], "status": "still_unmatched",
|
||||||
|
"rej": [r.get("reason") for r in it.get("rejections", [])]})
|
||||||
|
continue
|
||||||
|
files.append({
|
||||||
|
"path": it["path"], "seriesId": ser["id"],
|
||||||
|
"episodeIds": [e["id"] for e in eps], "quality": it["quality"],
|
||||||
|
"languages": it.get("languages") or [{"id": 1, "name": "English"}],
|
||||||
|
})
|
||||||
|
out.append({"file": it["relativePath"], "status": "import_queued",
|
||||||
|
"episodes": [f'S{e.get("seasonNumber"):02d}E{e.get("episodeNumber"):02d}' for e in eps]})
|
||||||
|
if files:
|
||||||
|
cmd = _req("POST", f"{url}/api/v3/command", key,
|
||||||
|
{"name": "ManualImport", "importMode": mode, "files": files}, timeout=timeout)
|
||||||
|
for o in out:
|
||||||
|
if o.get("status") == "import_queued":
|
||||||
|
o["commandId"] = cmd["id"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def popelis_import_media_drop(
|
||||||
|
radarr_url: str,
|
||||||
|
radarr_key: str,
|
||||||
|
sonarr_url: str,
|
||||||
|
sonarr_key: str,
|
||||||
|
movies_drop: str = "/data/manual/movies",
|
||||||
|
tv_drop: str = "/data/manual/tv",
|
||||||
|
movie_root: str = "/data/media/movies",
|
||||||
|
tv_root: str = "/data/media/tv",
|
||||||
|
quality_profile_id: int = 4,
|
||||||
|
import_mode: str = "move",
|
||||||
|
dry_run: bool = False,
|
||||||
|
series_refresh_wait: float = 6.0,
|
||||||
|
timeout: float = 60.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Importa los drops de pelis y series via Radarr/Sonarr con metadata.
|
||||||
|
|
||||||
|
Pelis (Radarr): para cada fichero sin match, hace lookup por titulo,
|
||||||
|
da de alta la pelicula (poster/fanart/ficha) si no existe y dispara un
|
||||||
|
ManualImport (movieId explicito) que mueve el fichero a movie_root.
|
||||||
|
|
||||||
|
Series (Sonarr): detecta series desconocidas, las da de alta, espera a
|
||||||
|
que Sonarr refresque la ficha de episodios, re-lista el drop (ahora los
|
||||||
|
SxxExx matchean) e importa cada episodio con su episodeId. Ficheros con
|
||||||
|
numeracion fuera de la ficha (ej. orden DVD vs aired) quedan reportados
|
||||||
|
como 'still_unmatched' y permanecen en el drop para resolucion manual.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
radarr_url: URL base de Radarr sin trailing slash. Ej http://localhost:7878
|
||||||
|
radarr_key: API key de Radarr (Settings > General).
|
||||||
|
sonarr_url: URL base de Sonarr. Ej http://localhost:8989
|
||||||
|
sonarr_key: API key de Sonarr.
|
||||||
|
movies_drop: Carpeta drop de pelis EN EL NAMESPACE de Radarr (/data/...).
|
||||||
|
tv_drop: Carpeta drop de series EN EL NAMESPACE de Sonarr (/data/...).
|
||||||
|
movie_root: Root folder de Radarr donde se mueven las pelis.
|
||||||
|
tv_root: Root folder de Sonarr donde se mueven las series.
|
||||||
|
quality_profile_id: Quality profile para altas nuevas (4 = HD-1080p).
|
||||||
|
import_mode: 'move' (default) o 'copy'. 'move' borra el origen.
|
||||||
|
dry_run: Si True, no escribe nada: solo reporta que haria.
|
||||||
|
series_refresh_wait: Segundos a esperar tras dar de alta una serie
|
||||||
|
antes de re-listar (Sonarr necesita refrescar).
|
||||||
|
timeout: Timeout HTTP por peticion.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict {"movies": [...], "tv": [...], "summary": {...}} con una entrada
|
||||||
|
por fichero/serie y su status (import_queued | added | no_match |
|
||||||
|
still_unmatched | would_*).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si una peticion HTTP devuelve 4xx/5xx.
|
||||||
|
urllib.error.URLError: Si no se puede conectar a una *arr.
|
||||||
|
"""
|
||||||
|
movies = _import_radarr(radarr_url.rstrip("/"), radarr_key, movies_drop,
|
||||||
|
movie_root, quality_profile_id, import_mode, dry_run, timeout)
|
||||||
|
tv = _import_sonarr(sonarr_url.rstrip("/"), sonarr_key, tv_drop, tv_root,
|
||||||
|
quality_profile_id, import_mode, dry_run, series_refresh_wait, timeout)
|
||||||
|
summary = {
|
||||||
|
"movies_queued": sum(1 for o in movies if o["status"] in ("import_queued", "would_add+import", "would_import")),
|
||||||
|
"movies_unmatched": sum(1 for o in movies if o["status"] == "no_match"),
|
||||||
|
"tv_queued": sum(1 for o in tv if o.get("status") == "import_queued"),
|
||||||
|
"tv_added_series": sum(1 for o in tv if o.get("status") in ("added", "would_add")),
|
||||||
|
"tv_unmatched": sum(1 for o in tv if o.get("status") in ("still_unmatched", "no_match")),
|
||||||
|
"dry_run": dry_run,
|
||||||
|
}
|
||||||
|
return {"movies": movies, "tv": tv, "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser(description="Importa drops de Popelis via Radarr/Sonarr")
|
||||||
|
p.add_argument("--radarr-url", default=os.environ.get("RADARR_URL", "http://localhost:7878"))
|
||||||
|
p.add_argument("--radarr-key", default=os.environ.get("RADARR_KEY", ""))
|
||||||
|
p.add_argument("--sonarr-url", default=os.environ.get("SONARR_URL", "http://localhost:8989"))
|
||||||
|
p.add_argument("--sonarr-key", default=os.environ.get("SONARR_KEY", ""))
|
||||||
|
p.add_argument("--apply", action="store_true", help="Ejecuta de verdad (default dry-run)")
|
||||||
|
args = p.parse_args()
|
||||||
|
res = popelis_import_media_drop(
|
||||||
|
args.radarr_url, args.radarr_key, args.sonarr_url, args.sonarr_key,
|
||||||
|
dry_run=not args.apply,
|
||||||
|
)
|
||||||
|
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: popelis_set_password
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def popelis_set_password(base_url: str, admin_token: str, username: str, password: str, timeout: float = 30.0) -> dict"
|
||||||
|
description: "Cambia la contrasena de un usuario existente en Popelis (POST /api/admin/users/password). Devuelve 404 si el usuario no existe. Registro CERRADO: requiere cabecera X-Admin-Token."
|
||||||
|
tags: [popelis, http, admin, user, password, infra]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servicio sin trailing slash. Ej: https://popelis.datardos.com"
|
||||||
|
- name: admin_token
|
||||||
|
desc: "Token de administracion. Se envia como cabecera X-Admin-Token. No logear ni exponer."
|
||||||
|
- name: username
|
||||||
|
desc: "Nombre del usuario al que se le quiere cambiar la contrasena. Debe existir previamente."
|
||||||
|
- name: password
|
||||||
|
desc: "Nueva contrasena a establecer."
|
||||||
|
- name: timeout
|
||||||
|
desc: "Timeout en segundos para la peticion HTTP. Default 30.0."
|
||||||
|
output: "Dict con el resultado de la operacion: {status: 'password updated'}"
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["json", "urllib.request", "urllib.error"]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/infra/popelis_set_password.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from infra.popelis_set_password import popelis_set_password
|
||||||
|
|
||||||
|
result = popelis_set_password(
|
||||||
|
base_url="https://popelis.datardos.com",
|
||||||
|
admin_token="<admin-token>",
|
||||||
|
username="alice",
|
||||||
|
password="n3wpass2024",
|
||||||
|
)
|
||||||
|
# result == {"status": "password updated"}
|
||||||
|
print(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites resetear o cambiar la contrasena de un usuario ya existente en Popelis desde un script de administracion o agente. Usar DESPUES de `popelis_create_user` (el usuario debe existir). Tambien util para rotacion periodica de credenciales.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Token sensible**: `admin_token` es un secreto. Nunca logear el valor, interpolarlo en URLs ni persistirlo en texto plano.
|
||||||
|
- **Registro cerrado**: el endpoint rechaza cualquier peticion sin `X-Admin-Token` valido (HTTP 401/403).
|
||||||
|
- **404 si no existe**: si el `username` no esta registrado, el servidor devuelve HTTP 404 y la funcion lanza `ValueError` con mensaje explicito (`usuario 'X' no existe`). Verificar existencia previa o capturar el error.
|
||||||
|
- **No afecta a Jellyfin**: este endpoint cambia solo la contrasena en Popelis. Si el usuario Jellyfin espejo tiene contrasena separada, habra que gestionarla por separado via la API de Jellyfin.
|
||||||
|
- **Solo stdlib**: no requiere `requests` ni dependencias externas — usa `urllib.request`.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Cambia la contrasena de un usuario en la API de administracion de Popelis."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def popelis_set_password(
|
||||||
|
base_url: str,
|
||||||
|
admin_token: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Cambia la contrasena de un usuario existente en Popelis.
|
||||||
|
|
||||||
|
Hace POST a {base_url}/api/admin/users/password con el token de admin,
|
||||||
|
el nombre de usuario y la nueva contrasena. Devuelve 404 si el usuario
|
||||||
|
no existe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servicio, sin trailing slash.
|
||||||
|
Ej: "https://popelis.datardos.com"
|
||||||
|
admin_token: Token de administracion (cabecera X-Admin-Token).
|
||||||
|
Mantenerlo en secreto — no logear.
|
||||||
|
username: Nombre de usuario cuya contrasena se quiere cambiar.
|
||||||
|
password: Nueva contrasena a establecer.
|
||||||
|
timeout: Timeout en segundos para la peticion HTTP. Default 30.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con el resultado: {"status": "password updated"}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si el servidor devuelve 4xx o 5xx (incluyendo 404 cuando
|
||||||
|
el usuario no existe). El mensaje incluye el status code
|
||||||
|
y el campo "error" del body JSON si existe.
|
||||||
|
urllib.error.URLError: Si no se puede conectar al servidor.
|
||||||
|
"""
|
||||||
|
url = f"{base_url.rstrip('/')}/api/admin/users/password"
|
||||||
|
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Admin-Token": admin_token,
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
body = resp.read()
|
||||||
|
return json.loads(body)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read()
|
||||||
|
try:
|
||||||
|
detail = json.loads(raw).get("error", raw.decode("utf-8", errors="replace"))
|
||||||
|
except Exception:
|
||||||
|
detail = raw.decode("utf-8", errors="replace")
|
||||||
|
if exc.code == 404:
|
||||||
|
raise ValueError(
|
||||||
|
f"popelis_set_password: usuario '{username}' no existe (HTTP 404) — {detail}"
|
||||||
|
) from exc
|
||||||
|
raise ValueError(
|
||||||
|
f"popelis_set_password: HTTP {exc.code} — {detail}"
|
||||||
|
) from exc
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
name: jupyter_run_all
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: notebook
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "jupyter_run_all(notebook_path: str, server_url: str, token: str, restart_kernel: bool, stop_on_error: bool, timeout_per_cell_s: int) -> dict"
|
||||||
|
description: "Ejecuta todas las celdas de codigo de un notebook Jupyter en orden, con reinicio opcional del kernel antes de empezar. Equivalente al boton 'Run All' del UI pero invocable desde CLI/MCP/agente. Persiste outputs a disco via REST."
|
||||||
|
tags: [jupyter, notebook, kernel, run-all, smoke-test, ci, execution, notebook]
|
||||||
|
uses_functions: [jupyter_run_cells_py_notebook]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [jupyter_kernel_client, urllib, json, time]
|
||||||
|
params:
|
||||||
|
- name: notebook_path
|
||||||
|
desc: "Ruta relativa al notebook desde la raiz del servidor Jupyter (ej: 'notebooks/analisis.ipynb')"
|
||||||
|
- name: server_url
|
||||||
|
desc: "URL base del servidor Jupyter (default http://localhost:8888)"
|
||||||
|
- name: token
|
||||||
|
desc: "Token de autenticacion del servidor. Vacio si no se requiere auth"
|
||||||
|
- name: restart_kernel
|
||||||
|
desc: "Si True, reinicia el kernel antes de ejecutar para garantizar estado limpio. Default True"
|
||||||
|
- name: stop_on_error
|
||||||
|
desc: "Si True, detiene la ejecucion cuando una celda produce error. Default True"
|
||||||
|
- name: timeout_per_cell_s
|
||||||
|
desc: "Timeout en segundos por celda. Default 600 (10 minutos)"
|
||||||
|
output: "Dict con notebook, code_cell_indices, executed (lista de resultados por celda con cell_index/execution_count/outputs/error/duration_s), stopped_at (indice de la celda donde se detuvo si hubo error), kernel_id y total_duration_s"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/notebook/jupyter_run_all.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI: ejecutar todas las celdas con kernel limpio
|
||||||
|
python -m notebook.jupyter_run_all notebooks/analisis.ipynb
|
||||||
|
|
||||||
|
# CLI: ejecutar sin reiniciar kernel y continuar si hay errores
|
||||||
|
python -m notebook.jupyter_run_all notebooks/analisis.ipynb --no-restart --continue-on-error
|
||||||
|
|
||||||
|
# CLI: salida JSON completa
|
||||||
|
python -m notebook.jupyter_run_all notebooks/analisis.ipynb --server http://localhost:8888
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Importar y usar desde Python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
|
||||||
|
from notebook.jupyter_run_all import jupyter_run_all
|
||||||
|
|
||||||
|
result = jupyter_run_all(
|
||||||
|
notebook_path="notebooks/analisis.ipynb",
|
||||||
|
server_url="http://localhost:8888",
|
||||||
|
token="",
|
||||||
|
restart_kernel=True,
|
||||||
|
stop_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Ejecutadas: {len(result['executed'])} celdas en {result['total_duration_s']}s")
|
||||||
|
if result["stopped_at"] is not None:
|
||||||
|
print(f"ERROR en celda {result['stopped_at']}")
|
||||||
|
failed = next(e for e in result["executed"] if e["cell_index"] == result["stopped_at"])
|
||||||
|
print(failed["error"])
|
||||||
|
```
|
||||||
|
|
||||||
|
Salida ejemplo:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notebook": "notebooks/analisis.ipynb",
|
||||||
|
"code_cell_indices": [0, 1, 2, 4, 6],
|
||||||
|
"executed": [
|
||||||
|
{"cell_index": 0, "execution_count": 1, "outputs": ["pandas 2.2.1"], "error": null, "duration_s": 0.312},
|
||||||
|
{"cell_index": 1, "execution_count": 2, "outputs": ["(1500, 12)"], "error": null, "duration_s": 0.085}
|
||||||
|
],
|
||||||
|
"stopped_at": null,
|
||||||
|
"kernel_id": "a1b2c3d4-...",
|
||||||
|
"total_duration_s": 4.217
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usar `jupyter_run_all` cuando necesitas:
|
||||||
|
- Smoke test de un notebook despues de cambiar dependencias (confirma que ejecuta de principio a fin).
|
||||||
|
- Validar un notebook en CI/CD o desde un agente sin abrir el UI de Jupyter Lab.
|
||||||
|
- Regenerar todos los outputs del notebook con estado limpio (restart_kernel=True).
|
||||||
|
- Detectar celdas que fallan antes de compartir o publicar el notebook.
|
||||||
|
|
||||||
|
No usar para ejecutar una sola celda — usar `jupyter_exec` (modo `cell` o `kernel`) para eso.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Requiere sesion activa**: el kernel del notebook debe estar corriendo. Si el notebook no esta abierto en Jupyter Lab, llamar antes a `jupyter_exec` para crear la sesion, o abrir el notebook manualmente. Error: `RuntimeError: No hay sesion activa`.
|
||||||
|
- **restart_kernel=True limpia TODO el estado**: variables, imports, estado de modulos. Si el notebook depende de estado previo (interactivo), usar `restart_kernel=False`.
|
||||||
|
- **stop_on_error=True es el default**: una celda con error detiene el resto. Para runs de diagnostico donde quieres ver todos los errores, pasar `stop_on_error=False`.
|
||||||
|
- **Timeout por celda**: `timeout_per_cell_s=600` (10 min) es el maximo por celda individual. Celdas con operaciones largas (entrenamiento ML, queries pesadas) pueden necesitar valor mayor.
|
||||||
|
- **Outputs se persisten a disco**: al terminar, el notebook se guarda via REST con los nuevos outputs. Jupyter Lab puede pedir "Revert to disk" si el usuario tiene cambios no guardados en el browser.
|
||||||
|
- **Celdas vacias se saltan**: una celda de codigo cuyo `source` es solo espacios o saltos de linea se omite (execution_count queda None, outputs=[]).
|
||||||
|
- **`jupyter_run_cells` como dependencia futura**: cuando `jupyter_run_cells_py_notebook` este disponible, el batch de ejecucion puede delegarse a esa funcion. Hoy la logica es autonoma.
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Ejecuta todas las celdas de codigo de un notebook en orden (Run All).
|
||||||
|
|
||||||
|
Equivalente al boton "Run All" del UI de Jupyter Lab, pero invocable desde
|
||||||
|
CLI/MCP/agente. Opcionalmente reinicia el kernel antes de empezar para
|
||||||
|
garantizar un estado limpio.
|
||||||
|
|
||||||
|
Depende de jupyter_run_cells cuando esta disponible; si no, ejecuta la logica
|
||||||
|
de batch internamente reutilizando los helpers de jupyter_exec.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from jupyter_kernel_client import KernelClient
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers REST (minimos, alineados con jupyter_exec)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(token: str, content_type: bool = False) -> dict[str, str]:
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if content_type:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _api_get(url: str, token: str = "") -> dict | list | None:
|
||||||
|
try:
|
||||||
|
req = Request(url, headers=_auth_headers(token))
|
||||||
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except (URLError, OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _api_post(url: str, token: str = "", body: dict | None = None) -> dict | None:
|
||||||
|
data = json.dumps(body or {}).encode("utf-8")
|
||||||
|
req = Request(url, data=data, headers=_auth_headers(token, content_type=True), method="POST")
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=30) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except (URLError, OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_kernel_id(server_url: str, token: str, notebook_path: str) -> str | None:
|
||||||
|
"""Busca el kernel_id activo para el notebook via /api/sessions."""
|
||||||
|
sessions = _api_get(f"{server_url}/api/sessions", token) or []
|
||||||
|
for session in sessions:
|
||||||
|
nb = session.get("notebook", session.get("path", {}))
|
||||||
|
nb_path = nb.get("path", nb) if isinstance(nb, dict) else str(nb)
|
||||||
|
if nb_path == notebook_path:
|
||||||
|
kernel = session.get("kernel", {})
|
||||||
|
return kernel.get("id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_notebook_content(notebook_path: str, server_url: str, token: str) -> dict:
|
||||||
|
"""Lee el notebook completo via /api/contents."""
|
||||||
|
url = f"{server_url}/api/contents/{notebook_path}?content=1&type=notebook"
|
||||||
|
req = Request(url, headers=_auth_headers(token))
|
||||||
|
with urlopen(req, timeout=15) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
|
||||||
|
|
||||||
|
def _put_notebook_content(notebook_path: str, server_url: str, token: str, content: dict) -> None:
|
||||||
|
"""Persiste el notebook via PUT /api/contents."""
|
||||||
|
body = json.dumps({"type": "notebook", "format": "json", "content": content}).encode("utf-8")
|
||||||
|
url = f"{server_url}/api/contents/{notebook_path}"
|
||||||
|
req = Request(url, data=body, headers=_auth_headers(token, content_type=True), method="PUT")
|
||||||
|
with urlopen(req, timeout=15) as resp:
|
||||||
|
resp.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_outputs(raw_outputs: list[dict]) -> list[str]:
|
||||||
|
"""Convierte outputs nbformat a strings legibles."""
|
||||||
|
result: list[str] = []
|
||||||
|
for output in raw_outputs:
|
||||||
|
output_type = output.get("output_type", "")
|
||||||
|
if output_type == "stream":
|
||||||
|
text = output.get("text", "")
|
||||||
|
if isinstance(text, list):
|
||||||
|
text = "".join(text)
|
||||||
|
result.append(text.rstrip("\n"))
|
||||||
|
elif output_type in ("display_data", "execute_result"):
|
||||||
|
data = output.get("data", {})
|
||||||
|
text = data.get("text/plain", "")
|
||||||
|
if isinstance(text, list):
|
||||||
|
text = "".join(text)
|
||||||
|
result.append(text.rstrip("\n"))
|
||||||
|
elif output_type == "error":
|
||||||
|
traceback = output.get("traceback", [])
|
||||||
|
result.append("\n".join(traceback))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _kernel_outputs_to_nbformat(outputs: list[dict]) -> list[dict]:
|
||||||
|
return [dict(o) for o in outputs]
|
||||||
|
|
||||||
|
|
||||||
|
def _restart_kernel_and_wait(server_url: str, token: str, kernel_id: str, poll_timeout_s: int = 30) -> None:
|
||||||
|
"""Reinicia el kernel y espera hasta que vuelva a estado idle."""
|
||||||
|
url = f"{server_url}/api/kernels/{kernel_id}/restart"
|
||||||
|
_api_post(url, token)
|
||||||
|
|
||||||
|
deadline = time.monotonic() + poll_timeout_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
kernels = _api_get(f"{server_url}/api/kernels", token) or []
|
||||||
|
for k in kernels:
|
||||||
|
if k.get("id") == kernel_id:
|
||||||
|
state = k.get("execution_state", "")
|
||||||
|
if state == "idle":
|
||||||
|
return
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API publica
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def jupyter_run_all(
|
||||||
|
notebook_path: str,
|
||||||
|
server_url: str = "http://localhost:8888",
|
||||||
|
token: str = "",
|
||||||
|
restart_kernel: bool = True,
|
||||||
|
stop_on_error: bool = True,
|
||||||
|
timeout_per_cell_s: int = 600,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Ejecuta todas las celdas de codigo del notebook en orden.
|
||||||
|
|
||||||
|
Equivalente al boton "Run All" del UI de Jupyter Lab. Si restart_kernel
|
||||||
|
es True, reinicia el kernel del notebook ANTES de empezar para garantizar
|
||||||
|
un estado completamente limpio (sin variables residuales de ejecuciones
|
||||||
|
anteriores).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_path: Ruta relativa al notebook desde la raiz del servidor
|
||||||
|
(ej: "notebooks/analisis.ipynb").
|
||||||
|
server_url: URL base del servidor Jupyter (default http://localhost:8888).
|
||||||
|
token: Token de autenticacion. Vacio si el servidor no requiere auth.
|
||||||
|
restart_kernel: Si True, reinicia el kernel antes de empezar.
|
||||||
|
Garantiza estado limpio. Default True.
|
||||||
|
stop_on_error: Si True, detiene la ejecucion cuando una celda produce
|
||||||
|
un error (output_type == "error"). Default True.
|
||||||
|
timeout_per_cell_s: Timeout en segundos por celda. Default 600 (10 min).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"notebook": str, # ruta del notebook
|
||||||
|
"code_cell_indices": [int], # indices de celdas de codigo ejecutadas
|
||||||
|
"executed": [ # resultado de cada celda ejecutada
|
||||||
|
{
|
||||||
|
"cell_index": int,
|
||||||
|
"execution_count": int | None,
|
||||||
|
"outputs": [str | dict],
|
||||||
|
"error": str | None, # mensaje del error si hubo, sino None
|
||||||
|
"duration_s": float,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stopped_at": int | None, # cell_index donde se detuvo por error
|
||||||
|
"kernel_id": str,
|
||||||
|
"total_duration_s": float,
|
||||||
|
}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: Si no existe sesion activa para el notebook (el kernel
|
||||||
|
no esta corriendo). Usar jupyter_exec para crear sesion.
|
||||||
|
HTTPError: Si el servidor Jupyter devuelve un error HTTP.
|
||||||
|
URLError: Si no se puede conectar al servidor.
|
||||||
|
"""
|
||||||
|
t0 = time.monotonic()
|
||||||
|
|
||||||
|
# 1. Obtener kernel_id del notebook
|
||||||
|
kernel_id = _resolve_kernel_id(server_url, token, notebook_path)
|
||||||
|
if not kernel_id:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No hay sesion activa para '{notebook_path}'. "
|
||||||
|
"Abre el notebook en Jupyter Lab o usa jupyter_exec para crear sesion."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Reiniciar kernel si se solicita
|
||||||
|
if restart_kernel:
|
||||||
|
_restart_kernel_and_wait(server_url, token, kernel_id)
|
||||||
|
|
||||||
|
# 3. Leer notebook y filtrar indices de celdas de codigo
|
||||||
|
file_node = _get_notebook_content(notebook_path, server_url, token)
|
||||||
|
nb = file_node["content"]
|
||||||
|
cells = nb.get("cells", [])
|
||||||
|
code_cell_indices = [
|
||||||
|
i for i, cell in enumerate(cells)
|
||||||
|
if cell.get("cell_type") == "code"
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4. Ejecutar cada celda en orden
|
||||||
|
executed: list[dict] = []
|
||||||
|
stopped_at: int | None = None
|
||||||
|
|
||||||
|
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
|
||||||
|
for cell_index in code_cell_indices:
|
||||||
|
cell = cells[cell_index]
|
||||||
|
source = cell.get("source", "")
|
||||||
|
if isinstance(source, list):
|
||||||
|
source = "".join(source)
|
||||||
|
|
||||||
|
# Saltar celdas vacias
|
||||||
|
if not source.strip():
|
||||||
|
executed.append({
|
||||||
|
"cell_index": cell_index,
|
||||||
|
"execution_count": None,
|
||||||
|
"outputs": [],
|
||||||
|
"error": None,
|
||||||
|
"duration_s": 0.0,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
cell_t0 = time.monotonic()
|
||||||
|
result = kernel.execute(source)
|
||||||
|
cell_duration = time.monotonic() - cell_t0
|
||||||
|
|
||||||
|
raw_outputs = result.get("outputs", [])
|
||||||
|
cell["outputs"] = _kernel_outputs_to_nbformat(raw_outputs)
|
||||||
|
cell["execution_count"] = result.get("execution_count")
|
||||||
|
|
||||||
|
# Detectar error en outputs
|
||||||
|
error_msg: str | None = None
|
||||||
|
for out in raw_outputs:
|
||||||
|
if out.get("output_type") == "error":
|
||||||
|
ename = out.get("ename", "Error")
|
||||||
|
evalue = out.get("evalue", "")
|
||||||
|
error_msg = f"{ename}: {evalue}"
|
||||||
|
break
|
||||||
|
|
||||||
|
executed.append({
|
||||||
|
"cell_index": cell_index,
|
||||||
|
"execution_count": result.get("execution_count"),
|
||||||
|
"outputs": _extract_outputs(raw_outputs),
|
||||||
|
"error": error_msg,
|
||||||
|
"duration_s": round(cell_duration, 3),
|
||||||
|
})
|
||||||
|
|
||||||
|
if error_msg and stop_on_error:
|
||||||
|
stopped_at = cell_index
|
||||||
|
break
|
||||||
|
|
||||||
|
# 5. Persistir notebook con outputs actualizados
|
||||||
|
_put_notebook_content(notebook_path, server_url, token, nb)
|
||||||
|
|
||||||
|
total_duration = time.monotonic() - t0
|
||||||
|
return {
|
||||||
|
"notebook": notebook_path,
|
||||||
|
"code_cell_indices": code_cell_indices,
|
||||||
|
"executed": executed,
|
||||||
|
"stopped_at": stopped_at,
|
||||||
|
"kernel_id": kernel_id,
|
||||||
|
"total_duration_s": round(total_duration, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Ejecuta todas las celdas de codigo de un notebook en orden (Run All)"
|
||||||
|
)
|
||||||
|
parser.add_argument("notebook", help="Ruta del notebook relativa al servidor Jupyter")
|
||||||
|
parser.add_argument("--server", default="http://localhost:8888", help="URL del servidor Jupyter")
|
||||||
|
parser.add_argument("--token", default="", help="Token de autenticacion")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-restart",
|
||||||
|
action="store_true",
|
||||||
|
help="No reiniciar el kernel antes de ejecutar",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--continue-on-error",
|
||||||
|
action="store_true",
|
||||||
|
help="Continuar ejecucion aunque una celda produzca error",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=int,
|
||||||
|
default=600,
|
||||||
|
help="Timeout en segundos por celda (default: 600)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = jupyter_run_all(
|
||||||
|
notebook_path=args.notebook,
|
||||||
|
server_url=args.server,
|
||||||
|
token=args.token,
|
||||||
|
restart_kernel=not args.no_restart,
|
||||||
|
stop_on_error=not args.continue_on_error,
|
||||||
|
timeout_per_cell_s=args.timeout,
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
except Exception as exc:
|
||||||
|
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: jupyter_run_cells
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: notebook
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "jupyter_run_cells(notebook_path: str, cell_indices: list[int], server_url: str, token: str, stop_on_error: bool, timeout_per_cell_s: int) -> dict"
|
||||||
|
description: "Ejecuta un lote de celdas existentes (por indice) en una sola conexion WebSocket. Un GET inicial + un PUT final. Latencia fija ~3s en vez de ~3s * N de jupyter_execute_cell individual."
|
||||||
|
tags: [jupyter, notebook, kernel, websocket, execution, cells, batch, notebook]
|
||||||
|
uses_functions: [jupyter_exec_py_notebook]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [jupyter_kernel_client, notebook.jupyter_exec]
|
||||||
|
params:
|
||||||
|
- name: notebook_path
|
||||||
|
desc: "Ruta relativa al notebook (relativa a la raiz del servidor Jupyter)"
|
||||||
|
- name: cell_indices
|
||||||
|
desc: "Lista de indices de celdas a ejecutar (0-based, en orden). Solo celdas de tipo code."
|
||||||
|
- name: server_url
|
||||||
|
desc: "URL del servidor Jupyter (default http://localhost:8888)"
|
||||||
|
- name: token
|
||||||
|
desc: "Token de autenticacion (default vacio = sin auth)"
|
||||||
|
- name: stop_on_error
|
||||||
|
desc: "Si True, para al primer output de tipo error. El PUT se hace con lo ejecutado hasta ese punto."
|
||||||
|
- name: timeout_per_cell_s
|
||||||
|
desc: "Timeout en segundos por cada ejecucion individual de celda (default 600)"
|
||||||
|
output: "Dict con notebook, executed (lista de resultados por celda), stopped_at, kernel_id y total_duration_s"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_run_cells_single_cell_returns_output"
|
||||||
|
- "test_run_cells_stops_on_error_by_default"
|
||||||
|
- "test_run_cells_no_stop_on_error_continues"
|
||||||
|
- "test_run_cells_invalid_index_raises"
|
||||||
|
- "test_run_cells_non_code_cell_raises"
|
||||||
|
- "e2e: test_e2e_run_cells_batch"
|
||||||
|
- "e2e: test_e2e_run_cells_stop_on_error"
|
||||||
|
test_file_path: "python/functions/notebook/tests/test_jupyter_run_cells.py"
|
||||||
|
file_path: "python/functions/notebook/jupyter_run_cells.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from notebook.jupyter_run_cells import jupyter_run_cells
|
||||||
|
|
||||||
|
result = jupyter_run_cells(
|
||||||
|
notebook_path="notebooks/analisis.ipynb",
|
||||||
|
cell_indices=[0, 1, 2, 5],
|
||||||
|
server_url="http://localhost:8888",
|
||||||
|
token="",
|
||||||
|
stop_on_error=True,
|
||||||
|
)
|
||||||
|
# {
|
||||||
|
# "notebook": "notebooks/analisis.ipynb",
|
||||||
|
# "executed": [
|
||||||
|
# {"cell_index": 0, "execution_count": 1, "outputs": ["import ok"], "error": None, "duration_s": 1.2},
|
||||||
|
# {"cell_index": 1, "execution_count": 2, "outputs": ["42"], "error": None, "duration_s": 0.1},
|
||||||
|
# ...
|
||||||
|
# ],
|
||||||
|
# "stopped_at": None,
|
||||||
|
# "kernel_id": "abc-123",
|
||||||
|
# "total_duration_s": 4.8,
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Indices como argumentos posicionales
|
||||||
|
python python/functions/notebook/jupyter_run_cells.py notebooks/analisis.ipynb 0 1 2 5
|
||||||
|
|
||||||
|
# Indices via stdin JSON (util desde scripts)
|
||||||
|
echo '[0, 1, 2, 5]' | python python/functions/notebook/jupyter_run_cells.py notebooks/analisis.ipynb
|
||||||
|
|
||||||
|
# No parar en error + timeout custom
|
||||||
|
python python/functions/notebook/jupyter_run_cells.py notebooks/analisis.ipynb 0 1 2 \
|
||||||
|
--no-stop-on-error --timeout 120
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites re-ejecutar varias celdas de un notebook existente y el overhead de abrir/cerrar N conexiones WS sea inaceptable (>3 celdas, celdas pesadas, o en pipelines automatizados). Sustituye a llamar `jupyter_execute_cell` N veces en bucle desde el agente.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Las celdas deben existir previamente en el notebook. Para anadir y ejecutar celdas nuevas, usar `jupyter_append_execute` de `jupyter_exec`.
|
||||||
|
- Solo ejecuta celdas de tipo `code`. Pasar el indice de una celda markdown lanza `ValueError` antes de abrir el WS.
|
||||||
|
- El `PUT` final se hace siempre, incluso si `stop_on_error` detiene el lote. El notebook queda con los outputs de las celdas ejecutadas hasta el punto de parada.
|
||||||
|
- `KernelClient` ignora `timeout_per_cell_s` si la implementacion subyacente no lo soporta (depende de la version de `jupyter-kernel-client`). En ese caso el timeout global del proceso es el unico limite.
|
||||||
|
- Si el servidor Jupyter no esta corriendo, `_ensure_session` lanza `URLError` inmediatamente (no hay retry incorporado).
|
||||||
|
- El WS se abre con el `kernel_id` de la sesion activa del notebook. Si el kernel muere entre el `_ensure_session` y la ejecucion, `KernelClient` lanzara una excepcion.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Ejecuta un lote de celdas existentes en una sola conexion WebSocket.
|
||||||
|
|
||||||
|
A diferencia de `jupyter_execute_cell` (que abre/cierra un WS por celda),
|
||||||
|
esta funcion comparte una unica sesion WS para todas las celdas del lote.
|
||||||
|
Un solo GET /api/contents al inicio + un solo PUT al final.
|
||||||
|
|
||||||
|
Latencia total: ~3s fija (overhead) + tiempo de ejecucion real de las celdas,
|
||||||
|
en lugar de ~3s * N (una conexion por celda con `jupyter_execute_cell`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jupyter_kernel_client import KernelClient
|
||||||
|
|
||||||
|
from notebook.jupyter_exec import (
|
||||||
|
_ensure_session,
|
||||||
|
_extract_outputs,
|
||||||
|
_get_notebook_content,
|
||||||
|
_kernel_outputs_to_nbformat,
|
||||||
|
_put_notebook_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def jupyter_run_cells(
|
||||||
|
notebook_path: str,
|
||||||
|
cell_indices: list[int],
|
||||||
|
server_url: str = "http://localhost:8888",
|
||||||
|
token: str = "",
|
||||||
|
stop_on_error: bool = True,
|
||||||
|
timeout_per_cell_s: int = 600,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Ejecuta una lista de celdas existentes (por indice) en un solo paso.
|
||||||
|
|
||||||
|
Abre UNA conexion WebSocket para todo el lote. Lee el notebook una vez al
|
||||||
|
inicio y lo persiste una vez al final. Si `stop_on_error` es True y una
|
||||||
|
celda produce un output de tipo 'error', el lote para en esa celda y el
|
||||||
|
PUT se hace con los outputs ejecutados hasta ese momento.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_path: Ruta relativa al notebook (relativa a la raiz del
|
||||||
|
servidor Jupyter).
|
||||||
|
cell_indices: Lista de indices de celdas a ejecutar (0-based, en
|
||||||
|
orden). Deben ser celdas de tipo 'code'.
|
||||||
|
server_url: URL del servidor Jupyter (default 'http://localhost:8888').
|
||||||
|
token: Token de autenticacion (default vacio = sin auth).
|
||||||
|
stop_on_error: Si True, para al encontrar el primer error en outputs.
|
||||||
|
timeout_per_cell_s: Timeout en segundos por cada ejecucion individual
|
||||||
|
de celda (pasado a KernelClient.execute).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"notebook": str, # notebook_path recibido
|
||||||
|
"executed": [ # celdas ejecutadas (en orden)
|
||||||
|
{
|
||||||
|
"cell_index": int,
|
||||||
|
"execution_count": int | None,
|
||||||
|
"outputs": list[str], # strings legibles (via _extract_outputs)
|
||||||
|
"error": str | None, # traceback si hubo error, else None
|
||||||
|
"duration_s": float,
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"stopped_at": int | None, # indice donde paro si stop_on_error
|
||||||
|
"kernel_id": str,
|
||||||
|
"total_duration_s": float,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
t_total_start = time.monotonic()
|
||||||
|
|
||||||
|
kernel_id = _ensure_session(server_url, token, notebook_path)
|
||||||
|
|
||||||
|
file_node = _get_notebook_content(notebook_path, server_url, token)
|
||||||
|
nb = file_node["content"]
|
||||||
|
cells = nb.get("cells", [])
|
||||||
|
|
||||||
|
# Validacion anticipada: todos los indices deben estar en rango y ser code
|
||||||
|
for idx in cell_indices:
|
||||||
|
if idx < 0 or idx >= len(cells):
|
||||||
|
raise IndexError(
|
||||||
|
f"cell_index {idx} fuera de rango (notebook tiene {len(cells)} celdas)"
|
||||||
|
)
|
||||||
|
if cells[idx].get("cell_type") != "code":
|
||||||
|
raise ValueError(
|
||||||
|
f"La celda {idx} no es de codigo (cell_type={cells[idx].get('cell_type')!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
executed: list[dict[str, Any]] = []
|
||||||
|
stopped_at: int | None = None
|
||||||
|
|
||||||
|
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
|
||||||
|
for idx in cell_indices:
|
||||||
|
cell = cells[idx]
|
||||||
|
source = cell.get("source", "")
|
||||||
|
if isinstance(source, list):
|
||||||
|
source = "".join(source)
|
||||||
|
|
||||||
|
t_cell_start = time.monotonic()
|
||||||
|
result = kernel.execute(source, timeout=timeout_per_cell_s)
|
||||||
|
duration_s = round(time.monotonic() - t_cell_start, 3)
|
||||||
|
|
||||||
|
raw_outputs = result.get("outputs", [])
|
||||||
|
cell["outputs"] = _kernel_outputs_to_nbformat(raw_outputs)
|
||||||
|
cell["execution_count"] = result.get("execution_count")
|
||||||
|
|
||||||
|
readable_outputs = _extract_outputs(raw_outputs)
|
||||||
|
|
||||||
|
# Detectar si hubo error
|
||||||
|
error_text: str | None = None
|
||||||
|
for out in raw_outputs:
|
||||||
|
if out.get("output_type") == "error":
|
||||||
|
tb = out.get("traceback", [])
|
||||||
|
error_text = "\n".join(tb) if isinstance(tb, list) else str(tb)
|
||||||
|
break
|
||||||
|
|
||||||
|
executed.append({
|
||||||
|
"cell_index": idx,
|
||||||
|
"execution_count": result.get("execution_count"),
|
||||||
|
"outputs": readable_outputs,
|
||||||
|
"error": error_text,
|
||||||
|
"duration_s": duration_s,
|
||||||
|
})
|
||||||
|
|
||||||
|
if stop_on_error and error_text is not None:
|
||||||
|
stopped_at = idx
|
||||||
|
break
|
||||||
|
|
||||||
|
# Persiste notebook con todos los outputs actualizados de una vez
|
||||||
|
_put_notebook_content(notebook_path, server_url, token, nb)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"notebook": notebook_path,
|
||||||
|
"executed": executed,
|
||||||
|
"stopped_at": stopped_at,
|
||||||
|
"kernel_id": kernel_id,
|
||||||
|
"total_duration_s": round(time.monotonic() - t_total_start, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Ejecuta un lote de celdas en un solo paso WS"
|
||||||
|
)
|
||||||
|
parser.add_argument("notebook", help="Ruta al notebook relativa al servidor")
|
||||||
|
parser.add_argument(
|
||||||
|
"indices",
|
||||||
|
nargs="*",
|
||||||
|
type=int,
|
||||||
|
help="Indices de celdas a ejecutar (0-based). Si se omiten, lee JSON de stdin.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--server", default="http://localhost:8888")
|
||||||
|
parser.add_argument("--token", default="")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-stop-on-error",
|
||||||
|
action="store_true",
|
||||||
|
help="Continuar aunque una celda emita error",
|
||||||
|
)
|
||||||
|
parser.add_argument("--timeout", type=int, default=600, help="Timeout por celda en segundos")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.indices:
|
||||||
|
indices = args.indices
|
||||||
|
else:
|
||||||
|
raw = sys.stdin.read().strip()
|
||||||
|
indices = json.loads(raw) if raw else []
|
||||||
|
|
||||||
|
if not indices:
|
||||||
|
print(json.dumps({"error": "No se especificaron indices de celdas"}), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = jupyter_run_cells(
|
||||||
|
notebook_path=args.notebook,
|
||||||
|
cell_indices=indices,
|
||||||
|
server_url=args.server,
|
||||||
|
token=args.token,
|
||||||
|
stop_on_error=not args.no_stop_on_error,
|
||||||
|
timeout_per_cell_s=args.timeout,
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
except Exception as exc:
|
||||||
|
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""Tests para jupyter_run_cells.
|
||||||
|
|
||||||
|
Cubre:
|
||||||
|
- Validacion de indices (rango y tipo de celda).
|
||||||
|
- Comportamiento stop_on_error (True/False).
|
||||||
|
- E2E contra un Jupyter Lab vivo si esta disponible (skip si no).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
|
||||||
|
|
||||||
|
from python.functions.notebook import jupyter_exec as jx
|
||||||
|
from python.functions.notebook import jupyter_run_cells as jrc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers para mocks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_nb(cells: list[dict]) -> dict:
|
||||||
|
"""Construye un dict de notebook minimo con las celdas dadas."""
|
||||||
|
return {
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5,
|
||||||
|
"metadata": {},
|
||||||
|
"cells": cells,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _code_cell(source: str) -> dict:
|
||||||
|
return {
|
||||||
|
"cell_type": "code",
|
||||||
|
"source": source,
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": None,
|
||||||
|
"metadata": {},
|
||||||
|
"id": "test-id",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _markdown_cell(source: str) -> dict:
|
||||||
|
return {
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"source": source,
|
||||||
|
"metadata": {},
|
||||||
|
"id": "md-id",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _kernel_result(outputs: list[dict], execution_count: int = 1) -> dict:
|
||||||
|
return {"outputs": outputs, "execution_count": execution_count, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_output(text: str) -> dict:
|
||||||
|
return {"output_type": "stream", "name": "stdout", "text": text}
|
||||||
|
|
||||||
|
|
||||||
|
def _error_output(ename: str = "ValueError", traceback: list[str] | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"output_type": "error",
|
||||||
|
"ename": ename,
|
||||||
|
"evalue": "bad value",
|
||||||
|
"traceback": traceback or [f"{ename}: bad value"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeKernel:
|
||||||
|
"""Simula KernelClient con una lista de resultados predefinidos."""
|
||||||
|
|
||||||
|
def __init__(self, results: list[dict]):
|
||||||
|
self._results = iter(results)
|
||||||
|
|
||||||
|
def execute(self, source: str, **kwargs) -> dict:
|
||||||
|
return next(self._results)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests unitarios
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_infra(nb: dict, kernel: _FakeKernel):
|
||||||
|
"""Context managers que parchean _ensure_session, _get/put_notebook y KernelClient."""
|
||||||
|
file_node = {"content": nb}
|
||||||
|
return [
|
||||||
|
patch.object(jrc, "_ensure_session", return_value="kernel-abc"),
|
||||||
|
patch.object(jrc, "_get_notebook_content", return_value=file_node),
|
||||||
|
patch.object(jrc, "_put_notebook_content"),
|
||||||
|
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=kernel),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cells_single_cell_returns_output():
|
||||||
|
nb = _make_nb([_code_cell("print(42)")])
|
||||||
|
fake_kernel = _FakeKernel([_kernel_result([_stream_output("42\n")], execution_count=1)])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(jrc, "_ensure_session", return_value="k1"),
|
||||||
|
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||||
|
patch.object(jrc, "_put_notebook_content") as mock_put,
|
||||||
|
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
|
||||||
|
):
|
||||||
|
result = jrc.jupyter_run_cells(
|
||||||
|
"nb.ipynb", [0], server_url="http://localhost:8888", token=""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["kernel_id"] == "k1"
|
||||||
|
assert result["stopped_at"] is None
|
||||||
|
assert len(result["executed"]) == 1
|
||||||
|
entry = result["executed"][0]
|
||||||
|
assert entry["cell_index"] == 0
|
||||||
|
assert entry["execution_count"] == 1
|
||||||
|
assert entry["outputs"] == ["42"]
|
||||||
|
assert entry["error"] is None
|
||||||
|
assert entry["duration_s"] >= 0
|
||||||
|
mock_put.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cells_stops_on_error_by_default():
|
||||||
|
nb = _make_nb([_code_cell("x=1"), _code_cell("bad"), _code_cell("ok")])
|
||||||
|
fake_kernel = _FakeKernel([
|
||||||
|
_kernel_result([_stream_output("1")], 1),
|
||||||
|
_kernel_result([_error_output("RuntimeError", ["RuntimeError: bad"])], 2),
|
||||||
|
# la celda 2 nunca deberia ejecutarse
|
||||||
|
])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(jrc, "_ensure_session", return_value="k2"),
|
||||||
|
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||||
|
patch.object(jrc, "_put_notebook_content"),
|
||||||
|
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
|
||||||
|
):
|
||||||
|
result = jrc.jupyter_run_cells("nb.ipynb", [0, 1, 2], stop_on_error=True)
|
||||||
|
|
||||||
|
assert result["stopped_at"] == 1
|
||||||
|
assert len(result["executed"]) == 2
|
||||||
|
assert result["executed"][1]["error"] is not None
|
||||||
|
assert "RuntimeError" in result["executed"][1]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cells_no_stop_on_error_continues():
|
||||||
|
nb = _make_nb([_code_cell("bad"), _code_cell("print('after')")])
|
||||||
|
fake_kernel = _FakeKernel([
|
||||||
|
_kernel_result([_error_output()], 1),
|
||||||
|
_kernel_result([_stream_output("after")], 2),
|
||||||
|
])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(jrc, "_ensure_session", return_value="k3"),
|
||||||
|
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||||
|
patch.object(jrc, "_put_notebook_content"),
|
||||||
|
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
|
||||||
|
):
|
||||||
|
result = jrc.jupyter_run_cells("nb.ipynb", [0, 1], stop_on_error=False)
|
||||||
|
|
||||||
|
assert result["stopped_at"] is None
|
||||||
|
assert len(result["executed"]) == 2
|
||||||
|
assert result["executed"][0]["error"] is not None
|
||||||
|
assert result["executed"][1]["outputs"] == ["after"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cells_invalid_index_raises():
|
||||||
|
nb = _make_nb([_code_cell("x=1")])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(jrc, "_ensure_session", return_value="k4"),
|
||||||
|
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||||
|
patch.object(jrc, "_put_notebook_content"),
|
||||||
|
patch("python.functions.notebook.jupyter_run_cells.KernelClient"),
|
||||||
|
):
|
||||||
|
with pytest.raises(IndexError, match="fuera de rango"):
|
||||||
|
jrc.jupyter_run_cells("nb.ipynb", [5])
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cells_non_code_cell_raises():
|
||||||
|
nb = _make_nb([_markdown_cell("# titulo")])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(jrc, "_ensure_session", return_value="k5"),
|
||||||
|
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||||
|
patch.object(jrc, "_put_notebook_content"),
|
||||||
|
patch("python.functions.notebook.jupyter_run_cells.KernelClient"),
|
||||||
|
):
|
||||||
|
with pytest.raises(ValueError, match="no es de codigo"):
|
||||||
|
jrc.jupyter_run_cells("nb.ipynb", [0])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# E2E (requiere Jupyter Lab corriendo)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
JUPYTER_VENV_BIN = Path("/home/lucas/fn_registry/analysis/pruebas_jupyter/.venv/bin")
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_http(url: str, timeout: float = 10.0) -> bool:
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=1):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.3)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def jupyter_server(tmp_path_factory):
|
||||||
|
"""Arranca un Jupyter Lab en puerto libre. Skip si las deps no estan."""
|
||||||
|
if not (JUPYTER_VENV_BIN / "jupyter-lab").exists():
|
||||||
|
pytest.skip("Jupyter Lab no disponible en pruebas_jupyter venv")
|
||||||
|
|
||||||
|
workdir = tmp_path_factory.mktemp("jupyter_run_cells_e2e")
|
||||||
|
(workdir / "notebooks").mkdir()
|
||||||
|
port = _free_port()
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
str(JUPYTER_VENV_BIN / "jupyter-lab"),
|
||||||
|
f"--port={port}",
|
||||||
|
"--no-browser",
|
||||||
|
"--ServerApp.token=",
|
||||||
|
"--ServerApp.password=",
|
||||||
|
"--ServerApp.disable_check_xsrf=True",
|
||||||
|
f"--ServerApp.root_dir={workdir}",
|
||||||
|
"--collaborative",
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
server_url = f"http://localhost:{port}"
|
||||||
|
if not _wait_http(f"{server_url}/api"):
|
||||||
|
proc.terminate()
|
||||||
|
pytest.skip("Jupyter Lab no levanto a tiempo")
|
||||||
|
|
||||||
|
yield server_url, workdir
|
||||||
|
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_run_cells_batch(jupyter_server):
|
||||||
|
"""Ejecuta 3 celdas en lote y verifica outputs y persistence."""
|
||||||
|
server_url, workdir = jupyter_server
|
||||||
|
|
||||||
|
# Prepara el notebook con 3 celdas via jupyter_exec
|
||||||
|
jx.jupyter_append_execute("notebooks/batch.ipynb", "x = 10", server_url=server_url)
|
||||||
|
jx.jupyter_append_execute("notebooks/batch.ipynb", "y = x * 3", server_url=server_url)
|
||||||
|
jx.jupyter_append_execute("notebooks/batch.ipynb", "print(x + y)", server_url=server_url)
|
||||||
|
|
||||||
|
# Ejecuta el lote
|
||||||
|
result = jrc.jupyter_run_cells(
|
||||||
|
"notebooks/batch.ipynb",
|
||||||
|
[0, 1, 2],
|
||||||
|
server_url=server_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["stopped_at"] is None
|
||||||
|
assert len(result["executed"]) == 3
|
||||||
|
assert result["executed"][2]["outputs"] == ["40"]
|
||||||
|
assert result["total_duration_s"] > 0
|
||||||
|
assert result["kernel_id"] != ""
|
||||||
|
|
||||||
|
# Verifica persistencia en disco
|
||||||
|
nb = json.loads((workdir / "notebooks" / "batch.ipynb").read_text())
|
||||||
|
assert nb["cells"][2]["execution_count"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_run_cells_stop_on_error(jupyter_server):
|
||||||
|
"""Verifica que stop_on_error detiene el lote en la celda con error."""
|
||||||
|
server_url, workdir = jupyter_server
|
||||||
|
|
||||||
|
jx.jupyter_append_execute("notebooks/stopper.ipynb", "a = 1", server_url=server_url)
|
||||||
|
jx.jupyter_append_execute("notebooks/stopper.ipynb", "raise ValueError('boom')", server_url=server_url)
|
||||||
|
jx.jupyter_append_execute("notebooks/stopper.ipynb", "print('no llego')", server_url=server_url)
|
||||||
|
|
||||||
|
result = jrc.jupyter_run_cells(
|
||||||
|
"notebooks/stopper.ipynb",
|
||||||
|
[0, 1, 2],
|
||||||
|
server_url=server_url,
|
||||||
|
stop_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["stopped_at"] == 1
|
||||||
|
assert len(result["executed"]) == 2
|
||||||
|
assert result["executed"][1]["error"] is not None
|
||||||
|
assert "ValueError" in result["executed"][1]["error"]
|
||||||
|
# La celda 2 no debe aparecer en executed
|
||||||
|
assert all(e["cell_index"] != 2 for e in result["executed"])
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: extract_hls_from_cdp_tab
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: pipelines
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def extract_hls_from_cdp_tab(debug_port: int = 9222, url_substring: str | None = None, live_capture_s: float = 6.0, timeout_s: float = 15.0) -> dict"
|
||||||
|
description: "Extrae URLs de manifiestos HLS (master.m3u8, index*.m3u8) de todas las pestañas e iframes de Chrome via CDP. Combina performance.getEntriesByType('resource') con escucha de eventos Network en vivo para capturar manifests ya cargados y los que se piden tras conectar."
|
||||||
|
tags: [navegator, cdp, hls, m3u8, scraping]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [json, re, sys, os, time, urllib.request, threading, websocket]
|
||||||
|
params:
|
||||||
|
- name: debug_port
|
||||||
|
desc: "Puerto de remote debugging de Chrome (default 9222). Chrome debe lanzarse con --remote-debugging-port=9222."
|
||||||
|
- name: url_substring
|
||||||
|
desc: "Si se especifica, solo inspecciona targets (pestañas/iframes) cuyo URL contiene este substring. Ej: 'luluvdo.com'. Si None, inspecciona todos."
|
||||||
|
- name: live_capture_s
|
||||||
|
desc: "Segundos de escucha de eventos Network.requestWillBeSent/responseReceived en vivo por target (default 6.0). Caza manifests que se piden despues de conectar."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout en segundos para conexion WebSocket y operaciones recv por target (default 15.0)."
|
||||||
|
output: "dict {status: 'ok'|'error', targets: [{url, masters, variants}], all_m3u8: [lista plana deduplicada], error: str}. Si no hay m3u8: status ok, listas vacias."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/pipelines/extract_hls_from_cdp_tab.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from pipelines.extract_hls_from_cdp_tab import extract_hls_from_cdp_tab
|
||||||
|
|
||||||
|
# Chrome lanzado con --remote-debugging-port=9222 --remote-allow-origins=*
|
||||||
|
# reproduciendo un video en luluvdo.com (o cualquier player hls.js)
|
||||||
|
r = extract_hls_from_cdp_tab(debug_port=9222, url_substring="luluvdo.com")
|
||||||
|
print(r["all_m3u8"])
|
||||||
|
# ['https://cdn.luluvdo.com/.../urlset/master.m3u8?e=28800&t=abc123']
|
||||||
|
|
||||||
|
# Sin filtro: escanea todas las pestanas e iframes
|
||||||
|
r = extract_hls_from_cdp_tab(debug_port=9222)
|
||||||
|
for target in r["targets"]:
|
||||||
|
if target["masters"]:
|
||||||
|
print(f"Tab: {target['url']}")
|
||||||
|
print(f" Master: {target['masters']}")
|
||||||
|
print(f" Variantes: {target['variants']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Lanzar Chrome con las flags necesarias:
|
||||||
|
```bash
|
||||||
|
# Desde WSL (Chrome Windows)
|
||||||
|
/mnt/c/Program\ Files/Google/Chrome/Application/chrome.exe \
|
||||||
|
--remote-debugging-port=9222 \
|
||||||
|
--remote-allow-origins="*" \
|
||||||
|
--user-data-dir=/tmp/chrome_cdp_profile
|
||||||
|
```
|
||||||
|
|
||||||
|
Con proxy NordVPN (para sitios bloqueados por ISP):
|
||||||
|
```bash
|
||||||
|
# 1. Levantar bridge
|
||||||
|
source bash/functions/infra/start_nordvpn_socks_bridge.sh
|
||||||
|
start_nordvpn_socks_bridge --port 8889
|
||||||
|
# 2. Lanzar Chrome con proxy
|
||||||
|
chrome.exe --proxy-server=http://127.0.0.1:8889 --remote-debugging-port=9222 --remote-allow-origins="*"
|
||||||
|
# 3. Extraer HLS
|
||||||
|
r = extract_hls_from_cdp_tab(9222)
|
||||||
|
# 4. Descargar con el mismo proxy
|
||||||
|
# yt-dlp --proxy http://127.0.0.1:8889 --referer https://luluvdo.com/ r["all_m3u8"][0]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando un video se esta reproduciendo en Chrome controlado por CDP y quieres la URL del HLS (master.m3u8) sin reversear el player. Funciona con luluvdo.com, streamwish, filemoon y cualquier player hls.js porque lee lo que el navegador YA pidio al CDN, sin tocar codigo ofuscado. El player ya descifro/cargo el manifest — tu solo lo recoges.
|
||||||
|
|
||||||
|
Combina con `start_nordvpn_socks_bridge_bash_infra` cuando el sitio esta bloqueado por ISP: Chrome con --proxy-server apunta al bridge local.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **`--remote-allow-origins=*` OBLIGATORIO**: Chrome rechaza conexiones CDP con 403 si falta este flag (o el origin concreto). El header `Origin: http://localhost` en `create_connection` tambien es necesario por el mismo motivo.
|
||||||
|
- **El `<video>` tiene src `blob:`** (hls.js demuxa en memoria) — NO sirve para obtener el manifest. El URL real solo aparece en performance entries o en eventos Network.
|
||||||
|
- **El player suele vivir en un iframe**, no en la pestana top — la funcion escanea TODOS los targets para no perderlo.
|
||||||
|
- **El token del master.m3u8 caduca** (ej. `e=28800` = ~8h) y suele estar ligado a la IP de salida. Descargar desde la MISMA IP y con el mismo proxy. Añadir el Referer del host del player: `yt-dlp --proxy <proxy> --referer https://<host>/ <master.m3u8>`.
|
||||||
|
- **websocket-client** debe estar en el venv: `uv pip install websocket-client`. Si falta, el error indica el comando exacto.
|
||||||
|
- **live_capture_s=0** desactiva la escucha en vivo (solo lee performance entries). Util si el video ya esta cargado y quieres rapidez.
|
||||||
|
- Si `url_substring` es demasiado especifico y el iframe tiene otro dominio (CDN), no pasara el filtro. Usar None para ver todos los targets y luego ajustar.
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Extrae URLs de manifiestos HLS (master.m3u8, index*.m3u8) de tabs Chrome via CDP."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import threading
|
||||||
|
|
||||||
|
HLS_PATTERN = re.compile(r'\.m3u8', re.IGNORECASE)
|
||||||
|
DATA_URL_PATTERN = re.compile(r'^data:', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_targets(debug_port: int) -> list:
|
||||||
|
"""Obtiene la lista de targets via /json/list."""
|
||||||
|
url = f"http://127.0.0.1:{debug_port}/json/list"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"No se pudo conectar a Chrome en {debug_port}: {e}. "
|
||||||
|
"Asegurate de que Chrome esta corriendo con --remote-debugging-port.") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _send_recv(ws, msg_id: int, method: str, params: dict, timeout: float = 10.0) -> dict:
|
||||||
|
"""Envía un mensaje CDP y espera la respuesta con el mismo id, ignorando eventos."""
|
||||||
|
import websocket as ws_module
|
||||||
|
|
||||||
|
payload = json.dumps({"id": msg_id, "method": method, "params": params})
|
||||||
|
ws.send(payload)
|
||||||
|
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
ws.sock.settimeout(min(remaining, 1.0))
|
||||||
|
try:
|
||||||
|
raw = ws.recv()
|
||||||
|
except ws_module.WebSocketTimeoutException:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if msg.get("id") == msg_id:
|
||||||
|
return msg
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_from_target(ws_url: str, timeout_s: float, live_capture_s: float) -> dict:
|
||||||
|
"""
|
||||||
|
Conecta a un target CDP y extrae URLs m3u8 via:
|
||||||
|
- Method A: performance.getEntriesByType('resource')
|
||||||
|
- Method B: Network events en vivo durante live_capture_s segundos
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import websocket as ws_module
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(
|
||||||
|
"websocket-client no esta instalado. "
|
||||||
|
"Instalar con: uv pip install websocket-client"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
masters: list[str] = []
|
||||||
|
variants: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def classify(url: str) -> None:
|
||||||
|
if not url or DATA_URL_PATTERN.match(url):
|
||||||
|
return
|
||||||
|
if not HLS_PATTERN.search(url):
|
||||||
|
return
|
||||||
|
if url in seen:
|
||||||
|
return
|
||||||
|
seen.add(url)
|
||||||
|
# "master" en el path o sin segmento de variante = master manifest
|
||||||
|
if re.search(r'master', url, re.IGNORECASE):
|
||||||
|
masters.append(url)
|
||||||
|
else:
|
||||||
|
variants.append(url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws = ws_module.create_connection(
|
||||||
|
ws_url,
|
||||||
|
timeout=timeout_s,
|
||||||
|
header=["Origin: http://localhost"],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e), "masters": [], "variants": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg_id = 1
|
||||||
|
|
||||||
|
# Method A: performance entries ya acumuladas
|
||||||
|
result = _send_recv(ws, msg_id, "Runtime.evaluate", {
|
||||||
|
"expression": (
|
||||||
|
"JSON.stringify("
|
||||||
|
" performance.getEntriesByType('resource')"
|
||||||
|
" .map(e => e.name)"
|
||||||
|
" .filter(n => /\\.m3u8/i.test(n))"
|
||||||
|
")"
|
||||||
|
),
|
||||||
|
"returnByValue": True,
|
||||||
|
}, timeout=timeout_s)
|
||||||
|
msg_id += 1
|
||||||
|
|
||||||
|
if result and not result.get("error"):
|
||||||
|
try:
|
||||||
|
val = result.get("result", {}).get("result", {}).get("value", "[]")
|
||||||
|
for url in json.loads(val):
|
||||||
|
classify(url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Method B: escucha Network events en vivo
|
||||||
|
_send_recv(ws, msg_id, "Network.enable", {}, timeout=timeout_s)
|
||||||
|
msg_id += 1
|
||||||
|
|
||||||
|
deadline = time.time() + live_capture_s
|
||||||
|
while time.time() < deadline:
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
ws.sock.settimeout(min(remaining, 0.5))
|
||||||
|
try:
|
||||||
|
raw = ws.recv()
|
||||||
|
except ws_module.WebSocketTimeoutException:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
method = msg.get("method", "")
|
||||||
|
params = msg.get("params", {})
|
||||||
|
if method == "Network.requestWillBeSent":
|
||||||
|
classify(params.get("request", {}).get("url", ""))
|
||||||
|
elif method == "Network.responseReceived":
|
||||||
|
classify(params.get("response", {}).get("url", ""))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"error": "", "masters": masters, "variants": variants}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_hls_from_cdp_tab(
|
||||||
|
debug_port: int = 9222,
|
||||||
|
url_substring: str | None = None,
|
||||||
|
live_capture_s: float = 6.0,
|
||||||
|
timeout_s: float = 15.0,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Extrae URLs de manifiestos HLS (.m3u8) de todas las pestañas e iframes
|
||||||
|
de un Chrome con remote debugging activo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
debug_port: Puerto de remote debugging de Chrome (default 9222).
|
||||||
|
url_substring: Si se especifica, solo inspecciona targets cuyo URL contiene este substring.
|
||||||
|
live_capture_s: Segundos de escucha de eventos Network en vivo por target.
|
||||||
|
timeout_s: Timeout de conexion websocket y recv por target.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con status, targets, all_m3u8 (lista plana deduplicada), error.
|
||||||
|
"""
|
||||||
|
# Verificar que websocket-client esta disponible antes de empezar
|
||||||
|
try:
|
||||||
|
import websocket # noqa: F401
|
||||||
|
except ImportError as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"targets": [],
|
||||||
|
"all_m3u8": [],
|
||||||
|
"error": (
|
||||||
|
"websocket-client no esta instalado. "
|
||||||
|
"Instalar con: uv pip install websocket-client"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Obtener lista de targets
|
||||||
|
try:
|
||||||
|
all_targets = _get_targets(debug_port)
|
||||||
|
except RuntimeError as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"targets": [],
|
||||||
|
"all_m3u8": [],
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Filtrar targets validos (page o iframe con wsUrl)
|
||||||
|
candidates = []
|
||||||
|
for t in all_targets:
|
||||||
|
t_type = t.get("type", "")
|
||||||
|
ws_url = t.get("webSocketDebuggerUrl", "")
|
||||||
|
t_url = t.get("url", "")
|
||||||
|
if t_type not in ("page", "iframe"):
|
||||||
|
continue
|
||||||
|
if not ws_url:
|
||||||
|
continue
|
||||||
|
if url_substring and url_substring not in t_url:
|
||||||
|
continue
|
||||||
|
candidates.append(t)
|
||||||
|
|
||||||
|
# 3. Inspeccionar cada target
|
||||||
|
results = []
|
||||||
|
all_m3u8_set: set[str] = set()
|
||||||
|
|
||||||
|
for t in candidates:
|
||||||
|
ws_url = t["webSocketDebuggerUrl"]
|
||||||
|
t_url = t.get("url", "")
|
||||||
|
|
||||||
|
data = _collect_from_target(ws_url, timeout_s=timeout_s, live_capture_s=live_capture_s)
|
||||||
|
|
||||||
|
for u in data["masters"] + data["variants"]:
|
||||||
|
all_m3u8_set.add(u)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"url": t_url,
|
||||||
|
"masters": data["masters"],
|
||||||
|
"variants": data["variants"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"targets": results,
|
||||||
|
"all_m3u8": sorted(all_m3u8_set),
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Extrae URLs HLS de Chrome via CDP")
|
||||||
|
parser.add_argument("--debug-port", type=int, default=9222)
|
||||||
|
parser.add_argument("--url-substring", type=str, default=None)
|
||||||
|
parser.add_argument("--live-capture-s", type=float, default=6.0)
|
||||||
|
parser.add_argument("--timeout-s", type=float, default=15.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result = extract_hls_from_cdp_tab(
|
||||||
|
debug_port=args.debug_port,
|
||||||
|
url_substring=args.url_substring,
|
||||||
|
live_capture_s=args.live_capture_s,
|
||||||
|
timeout_s=args.timeout_s,
|
||||||
|
)
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
+7
-1
@@ -500,7 +500,13 @@ func walkMD(dir string, fn func(path string)) {
|
|||||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") {
|
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if filepath.Base(path) == "module.md" {
|
base := filepath.Base(path)
|
||||||
|
if base == "module.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Skip module-level doc files that are not function/type entries.
|
||||||
|
switch base {
|
||||||
|
case "MIGRATION.md", "README.md", "CHANGELOG.md":
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fn(path)
|
fn(path)
|
||||||
|
|||||||
@@ -417,3 +417,40 @@ repos:
|
|||||||
# Ver .claude/rules/cpp_apps.md §11 para convencion de uso.
|
# Ver .claude/rules/cpp_apps.md §11 para convencion de uso.
|
||||||
# Clone: git clone --depth=1 https://github.com/phosphor-icons/core.git sources/phosphor-core
|
# Clone: git clone --depth=1 https://github.com/phosphor-icons/core.git sources/phosphor-core
|
||||||
date: 2026-05-16
|
date: 2026-05-16
|
||||||
|
|
||||||
|
- repo: https://github.com/element-hq/synapse
|
||||||
|
license: AGPL-3.0
|
||||||
|
cloned_dir: synapse
|
||||||
|
extracted: []
|
||||||
|
# Matrix homeserver reference implementation (Python + Rust).
|
||||||
|
# Cloned for study: project matrix_chats — pub/sub eventing for Organic Machine.
|
||||||
|
# Key paths: synapse/notifier.py, synapse/handlers/sync.py, synapse/federation/sender/.
|
||||||
|
# Clone: git clone --depth=1 https://github.com/element-hq/synapse.git sources/synapse
|
||||||
|
date: 2026-05-26
|
||||||
|
|
||||||
|
- repo: https://github.com/element-hq/dendrite
|
||||||
|
license: Apache-2.0
|
||||||
|
cloned_dir: dendrite
|
||||||
|
extracted: []
|
||||||
|
# Matrix homeserver Go (maintenance mode). Cloned for architectural reference.
|
||||||
|
# Useful: setup/jetstream/streams.go (NATS internal event bus),
|
||||||
|
# syncapi/notifier/, roomserver/, federationapi/.
|
||||||
|
# Clone: git clone --depth=1 https://github.com/element-hq/dendrite.git sources/dendrite
|
||||||
|
date: 2026-05-26
|
||||||
|
|
||||||
|
- repo: https://codeberg.org/continuwuity/continuwuity
|
||||||
|
license: Apache-2.0
|
||||||
|
cloned_dir: continuwuity
|
||||||
|
extracted: []
|
||||||
|
# Matrix homeserver Rust, community fork of conduwuit. Lightweight, fast.
|
||||||
|
# Key paths: src/api/client/, src/service/{federation,appservice,sending}, src/database/.
|
||||||
|
# Clone: git clone --depth=1 https://codeberg.org/continuwuity/continuwuity.git sources/continuwuity
|
||||||
|
date: 2026-05-26
|
||||||
|
|
||||||
|
- repo: https://github.com/x86pup/conduwuit
|
||||||
|
license: Apache-2.0
|
||||||
|
cloned_dir: conduwuit
|
||||||
|
extracted: []
|
||||||
|
# ARCHIVED 2026-01-19. Cloned for historical reference; superseded by continuwuity.
|
||||||
|
# Clone: git clone --depth=1 https://github.com/x86pup/conduwuit.git sources/conduwuit
|
||||||
|
date: 2026-05-26
|
||||||
|
|||||||
Reference in New Issue
Block a user