Labmagic 1.0: control de la Blackmagic Pocket 4K por Bluetooth, panel para OBS y licencia MIT
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
10
.gitignore
vendido
Archivo normal
@@ -0,0 +1,10 @@
|
||||
# Estado local de la app
|
||||
config.json
|
||||
labmagic.log
|
||||
|
||||
# Compilación
|
||||
__pycache__/
|
||||
*.pyc
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
18
Compilar exe.bat
Archivo normal
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
rem Genera dist\Labmagic.exe (un solo archivo, sin consola) con PyInstaller.
|
||||
cd /d "%~dp0"
|
||||
py -3.12 -m pip install --quiet pyinstaller bleak pillow
|
||||
py -3.12 make_icon.py
|
||||
py -3.12 -m PyInstaller --noconfirm --clean --onefile --windowed --name "Labmagic" ^
|
||||
--icon "%~dp0icono.ico" --add-data "%~dp0icono.png;." --add-data "%~dp0dock.html;." ^
|
||||
--collect-submodules winrt --collect-submodules bleak --copy-metadata bleak ^
|
||||
--workpath "%TEMP%\labmagic-build" --specpath "%TEMP%\labmagic-build" --distpath "%~dp0dist" app.py
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Error al compilar.
|
||||
pause
|
||||
) else (
|
||||
echo.
|
||||
echo Listo: "%~dp0dist\Labmagic.exe"
|
||||
pause
|
||||
)
|
||||
8
Iniciar Labmagic.bat
Archivo normal
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
py -3.12 app.py
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo La aplicacion ha terminado con error. Revisa el mensaje de arriba.
|
||||
pause
|
||||
)
|
||||
53
LICENCIA-ES.md
Archivo normal
@@ -0,0 +1,53 @@
|
||||
# Licencia y condiciones de uso (resumen en español)
|
||||
|
||||
Labmagic es software de **Labstream S.A.S.** (NIT 900.665.024-5, Bogotá, Colombia) y se
|
||||
distribuye bajo la **Licencia MIT**. El texto legal que vale es el del archivo `LICENSE`
|
||||
(en inglés, como es habitual en esta licencia); esto es una explicación.
|
||||
|
||||
## Qué puedes hacer
|
||||
|
||||
- **Descargarlo, instalarlo y usarlo** gratis, para lo que quieras, también en trabajos
|
||||
comerciales.
|
||||
- **Copiarlo, modificarlo y redistribuirlo**, entero o en parte, con o sin cambios.
|
||||
- Incluirlo en otros programas, incluso de pago.
|
||||
|
||||
## Única condición
|
||||
|
||||
- Conservar el aviso de copyright y el texto de la licencia (`LICENSE`) en cualquier copia o
|
||||
parte sustancial del programa.
|
||||
|
||||
## Sin garantía
|
||||
|
||||
El programa se entrega **tal cual**, sin garantía de ningún tipo. Labstream no responde por
|
||||
daños derivados de su uso. En concreto, Labmagic envía órdenes a una cámara real: cambia
|
||||
ajustes, mueve el objetivo, inicia y detiene grabaciones y puede apagar la cámara. Úsalo con
|
||||
criterio y comprueba la configuración antes de un rodaje.
|
||||
|
||||
## Marcas y protocolo
|
||||
|
||||
- **Blackmagic**, **Blackmagic Design**, **Pocket Cinema Camera** y **Blackmagic RAW** son
|
||||
marcas de Blackmagic Design Pty. Ltd. Labmagic no es un producto de Blackmagic Design ni
|
||||
está respaldado por ella. Usa el *Blackmagic Camera Control Protocol*, que Blackmagic
|
||||
publica en el manual de sus cámaras para que terceros lo implementen.
|
||||
- **Metabones** es marca de Metabones Inc.
|
||||
- **OBS Studio** es un proyecto de la OBS Project, licencia GPL v2; Labmagic no lo modifica ni
|
||||
lo incluye, solo se acopla a él como panel de navegador.
|
||||
- **Labstream** y su logotipo son de Labstream S.A.S.: la licencia MIT cubre el código, no
|
||||
autoriza a usar la marca ni el logotipo para presentar otro producto como de Labstream.
|
||||
|
||||
## Datos personales
|
||||
|
||||
Labmagic no envía datos a internet ni a Labstream. Guarda en este PC un `config.json` con la
|
||||
dirección Bluetooth de la cámara y tus preferencias, y un `labmagic.log` con el registro de
|
||||
la sesión. El panel para OBS escucha solo en `127.0.0.1` (este equipo).
|
||||
|
||||
## Componentes de terceros
|
||||
|
||||
Ver `TERCEROS.md`: Python, Tcl/Tk, bleak, pywinrt y PyInstaller, con sus licencias.
|
||||
|
||||
## Aviso de Windows al descargar
|
||||
|
||||
El ejecutable no lleva firma digital de pago. Windows puede mostrar «Windows protegió tu PC»
|
||||
(SmartScreen): pulsa «Más información» y «Ejecutar de todas formas». En equipos con **Smart
|
||||
App Control** activado el .exe no abre; ahí se usa el acceso directo con Python
|
||||
(`README.md`, «Por qué el .exe no abre en este PC»).
|
||||
21
LICENSE
Archivo normal
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Labstream S.A.S. (NIT 900.665.024-5), Bogotá, Colombia
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
138
README.md
Archivo normal
@@ -0,0 +1,138 @@
|
||||
# Labmagic
|
||||
|
||||
Aplicación sencilla de Windows para controlar una Blackmagic Pocket Cinema Camera por
|
||||
Bluetooth LE: ver si está conectada, disparar el autofoco, mover foco y diafragma, grabar,
|
||||
y ajustar ISO, obturador, balance de blancos, LUT y otras funciones.
|
||||
|
||||
Probada el 8 de octubre de 2026 con la cámara `A:4A5A3B39` (dirección `90:FD:9F:C4:AD:B2`),
|
||||
protocolo Blackmagic 0.1.0, Windows 11 y Python 3.12.
|
||||
|
||||
## Cómo arrancarla
|
||||
|
||||
- **En este PC**: icono «Labmagic» en el escritorio y en el menú Inicio. Abre la app sin
|
||||
consola (usa el `pythonw.exe` firmado de Python 3.12, que Windows sí deja ejecutar).
|
||||
- **En otro PC**: `dist\Labmagic.exe` (un solo archivo, sin consola, no necesita Python).
|
||||
Tarda 2 o 3 segundos en abrir: no hace falta pulsar dos veces.
|
||||
- **Desde el código**: doble clic en `Iniciar Labmagic.bat` (o `py -3.12 app.py`).
|
||||
Requiere Python 3.12 y `bleak` 3.x (`py -3.12 -m pip install -r requirements.txt`).
|
||||
- **Recompilar el .exe** tras cambiar el código: doble clic en `Compilar exe.bat`.
|
||||
|
||||
### Por qué el .exe no abre en este PC
|
||||
|
||||
Este equipo tiene activado **Smart App Control** de Windows 11. Bloquea cualquier .exe sin firma
|
||||
digital con reputación, y cada compilación de PyInstaller es un archivo nuevo sin firma, así
|
||||
que lo bloquea («Una directiva de Control de aplicaciones bloqueó este archivo», evento 3118
|
||||
de CodeIntegrity). Por eso aquí la app se lanza con los accesos directos a `pythonw.exe`.
|
||||
Opciones para que el .exe funcione en este PC: firmarlo con un certificado de firma de código
|
||||
reconocido, o desactivar Smart App Control (Seguridad de Windows > Control de aplicaciones y
|
||||
navegador; una vez apagado no se puede volver a encender sin reinstalar Windows). En un PC sin
|
||||
Smart App Control el .exe abre sin más.
|
||||
|
||||
Probado el 8 de octubre de 2026 con el .exe y la cámara real: conexión, autofoco, barras de
|
||||
color, grabar y parar (la cámara confirma cada orden y el timecode corre durante la grabación).
|
||||
|
||||
## Panel dentro de OBS
|
||||
|
||||
Labmagic sirve una versión web de su panel mini en `http://127.0.0.1:8790/` (archivos
|
||||
`obs_dock.py` y `dock.html`). OBS Studio 28 o superior la puede acoplar como un dock más con
|
||||
**Docks → Custom Browser Docks** (nombre `Labmagic`, esa URL). El panel habla con la app, que es
|
||||
quien tiene la conexión Bluetooth (la cámara solo admite un proceso a la vez): si Labmagic no está
|
||||
abierta, el dock lo avisa y se conecta solo en cuanto la abres.
|
||||
|
||||
- Tiene lo mismo que la vista mini: HUD con timecode y celdas FPS · SHUTTER · IRIS · ISO · WB ·
|
||||
TINT (pulsables, abren una hoja inferior estilo cámara), AF, PEAK (clic derecho: color), zebra,
|
||||
falso color, AF auto, foco e iris con pasos, REC, línea de formato y «abrir Labmagic».
|
||||
- Teclado con el dock enfocado: espacio = AF, ← → = foco (Mayús = pasos grandes).
|
||||
- En **AJUSTES → Panel para OBS** se apaga o enciende, se copia la URL y el botón **Añadir a OBS**
|
||||
lo registra en OBS (con OBS cerrado; guarda copia `user.ini.labmagic.bak`). Si el puerto 8790
|
||||
está ocupado la app prueba los siguientes y guarda el elegido en `config.json` (`obs_port`).
|
||||
|
||||
## Uso
|
||||
|
||||
1. **Emparejar la cámara** con el botón **Emparejar (PIN)** de la app: quita el emparejamiento
|
||||
viejo de Windows, pide uno nuevo y abre una ventana para escribir el PIN de 6 cifras que la
|
||||
cámara muestra en pantalla (en la cámara, Bluetooth activado en Ajustes > Setup). Hay que
|
||||
repetirlo si la cámara olvida el PC (la app lo avisa: «acceso denegado al servicio de control»
|
||||
y el estado dice «Hace falta emparejar»). También vale emparejar desde Configuración de
|
||||
Windows > Bluetooth y dispositivos.
|
||||
2. Abrir la app. Busca sola las cámaras emparejadas y las que se anuncian y, con **Conectar al
|
||||
abrir** activado (por defecto), conecta sola con la cámara de la última vez. Si no, elige la de
|
||||
la lista y pulsa **Conectar**. El punto verde y la línea de estado indican conexión, cámara
|
||||
encendida, versión de protocolo y alimentación.
|
||||
3. **HUD superior, como la pantalla de la Pocket**: timecode en grande (se pone rojo y parpadea
|
||||
un punto al grabar) y las celdas FPS · SHUTTER · IRIS · ISO · WB · TINT con los valores actuales.
|
||||
Pulsar una celda abre un menú rápido: ángulos y velocidades de obturador, f/ del diafragma
|
||||
(y Auto iris), ISO, Kelvin (y Auto WB) y tinte.
|
||||
4. **Organización como la cámara, en pestañas**: PRINCIPAL (enfoque, diafragma y zoom, ISO /
|
||||
obturador / balance de blancos, REC), GRABACIÓN (formato, códec, rango dinámico,
|
||||
autoexposición), MONITOR (ayudas y overlays por salida, LUT, barras, reset de color) y AJUSTES
|
||||
(cámara, emparejar, conectar al abrir, apagar, registro). El HUD queda siempre arriba.
|
||||
5. Controles:
|
||||
- **Enfoque**: botón grande de autofoco (también con la **barra espaciadora**); junto a él el
|
||||
conmutador **PEAKING** (focus assist con líneas de color, color elegible: rojo, verde, azul,
|
||||
blanco o negro) para ver dónde está enfocando; **AF automático cada N segundos** para mantener
|
||||
enfocado a alguien sin tocar nada; deslizador de foco manual y pasos ◀ ▶.
|
||||
**Con objetivos EF por adaptador (24-105 con el Metabones 0.64x) hace falta el firmware del
|
||||
adaptador al día** (Metabones App, V5.10 en oct-2026): con el firmware viejo la cámara ignoraba
|
||||
AF y foco manual; con el nuevo, medido el 9-oct-2026 sobre el HDMI, el AF, las posiciones
|
||||
absolutas (0,05 / 0,5 / 0,95) y los pasos relativos (±0,2) mueven el objetivo. El objetivo EF
|
||||
sigue sin informar de su posición: la app muestra la última posición enviada y tras un AF la
|
||||
da por desconocida («—») hasta que se mueve el deslizador. Flechas ← → = pasos de 0,01,
|
||||
con Mayús = 0,05; barra espaciadora = AF.
|
||||
**Verificación del AF (9-oct-2026)**: el paquete que envía la app es byte a byte el del ejemplo
|
||||
oficial del manual (`ff 04 00 00 00 01 00 00`). Midiendo la nitidez del HDMI en OBS a 10
|
||||
muestras/s, las barras de color y el auto iris cambian la imagen al instante, pero tras el AF
|
||||
el objetivo no se mueve nada (ni barrido ni cambio). Es decir, la cámara recibe la orden y el
|
||||
objetivo no la ejecuta. Lista de comprobación: (1) interruptor del objetivo en **AF**;
|
||||
(2) pulsar el botón **Focus** físico de la cámara: si tampoco enfoca, el adaptador no da AF con
|
||||
ese objetivo (Metabones XL 0.64x necesita firmware 3.x actualizado con la app de Metabones por
|
||||
USB; Viltrox EF-M2 igual); (3) si el botón físico enfoca y la app no, avisar para investigar.
|
||||
- **Diafragma**: auto iris, deslizador 0 (abierto) a 1 (cerrado) y pasos; se muestra el f/ real
|
||||
que informa la cámara.
|
||||
- **Zoom**: deslizador para objetivos motorizados; la focal en mm se muestra siempre.
|
||||
- **Grabación**: botón GRABAR / PARAR, estado de transporte, formato y códec. Debajo, el panel
|
||||
**Formato de grabación**: códec (BRAW Q0/Q1/Q3/Q5/3:1/5:1/8:1/12:1, ProRes HQ/422/LT/Proxy),
|
||||
resolución (DCI 4K, 4096×1720, UHD, 2.6K, HD), fotogramas (23.98 a 60), fotogramas del sensor
|
||||
para off-speed y ventana del sensor; **Aplicar formato** envía códec (10.0) y formato (1.9). La
|
||||
cámara responde con el formato real que ha aceptado y el panel se actualiza con él.
|
||||
- **Monitor y utilidades**: selector **Aplicar en** (HDMI, pantalla LCD o ambas; por defecto HDMI
|
||||
porque la cámara va remota). Las ayudas (zebra, peaking, falso color) y los overlays (texto de
|
||||
estado, guías de encuadre, señal limpia) se envían con el campo «pantallas destino» del
|
||||
protocolo (4.1 y 3.0), así que afectan solo a la salida elegida.
|
||||
- **Exposición y color**: ISO, ángulo o velocidad de obturador, balance de blancos (Kelvin y
|
||||
tinte, presets y Auto WB), modo de autoexposición, rango dinámico y LUT de monitor.
|
||||
- **Monitor y utilidades**: zebra, peaking, falso color, barras de color, reset de corrección
|
||||
de color y apagar la cámara (con confirmación).
|
||||
- **Registro**: todo lo que se envía y lo que la cámara responde, con nombres legibles.
|
||||
|
||||
## Detalles que conviene saber
|
||||
|
||||
- **Al conectar, la app pide a la cámara toda su configuración.** La cámara solo la envía sola
|
||||
cuando el enlace Bluetooth es nuevo, pero escribir el valor 0x21 (Camera Ready sin «Initial
|
||||
Payload Received») en la característica Camera Status hace que la reenvíe siempre. Así el HUD
|
||||
(FPS, obturador, iris, ISO, WB, tinte), el formato, el códec y el objetivo se rellenan en unos
|
||||
6 segundos tras cada conexión, aunque Windows reutilice un enlace ya abierto.
|
||||
- En este firmware la operación «offset» del protocolo se comporta como asignación absoluta,
|
||||
por eso los pasos ◀ ▶ del diafragma solo se activan cuando se conoce el valor actual.
|
||||
- Las ayudas de monitor (zebra, peaking, falso color) y el apagado por Bluetooth dependen del
|
||||
firmware; si no cambian nada en la cámara es que no las acepta por este canal.
|
||||
- Los nombres de las variantes de códec (BRAW 5:1, ProRes HQ, etc.) siguen la tabla del
|
||||
Blackmagic Camera Control Developer Guide; si alguno no coincide con la cámara, es solo etiqueta.
|
||||
- `config.json` guarda la última cámara usada y el tamaño de la ventana.
|
||||
- Solo puede haber una copia abierta: si se vuelve a lanzar, trae al frente la que ya está.
|
||||
- Estética: negro y gris oscuro como la pantalla de la cámara, barra de título oscura. Icono:
|
||||
cuadrado rojo con bordes redondeados, borde blanco y el «lab» del wordmark real de Labstream
|
||||
más un punto blanco (opción 2 de `iconos/opciones.png`; `make_icon.py` genera las cuatro
|
||||
opciones a partir de `0_Modelo Propuestas/Logos/Logo Blanco.png`; para cambiarla, ajusta
|
||||
`OPCION_POR_DEFECTO` y vuelve a ejecutarlo).
|
||||
- El «acceso denegado al servicio de control» al conectar casi siempre significa que otra
|
||||
aplicación de este PC ya está conectada a la cámara (otra copia de Labmagic, un script o la
|
||||
ventana de Configuración > Bluetooth); solo si no es eso hay que volver a emparejar.
|
||||
|
||||
## Archivos
|
||||
|
||||
- `app.py`: ventana (Tkinter).
|
||||
- `bmpcc_ble.py`: protocolo Blackmagic Camera Control sobre BLE (bleak), búsqueda de cámaras
|
||||
emparejadas y anunciadas, envío y decodificación de parámetros.
|
||||
- `Iniciar Labmagic.bat`: lanzador.
|
||||
- `requirements.txt`: dependencias de Python.
|
||||
17
TERCEROS.md
Archivo normal
@@ -0,0 +1,17 @@
|
||||
# Componentes de terceros incluidos o usados por Labmagic
|
||||
|
||||
| Componente | Para qué | Licencia | Dónde |
|
||||
|---|---|---|---|
|
||||
| Python 3.12 | lenguaje y biblioteca estándar | PSF License (compatible con MIT, permite redistribuir) | https://www.python.org/ |
|
||||
| Tcl/Tk (tkinter) | ventanas y controles | Tcl/Tk License (tipo BSD) | https://www.tcl.tk/ |
|
||||
| bleak 3.x | Bluetooth LE en Windows | MIT | https://github.com/hbldh/bleak |
|
||||
| pywinrt (winrt-*) | acceso a la API de Windows para emparejar | MIT | https://github.com/pywinrt/pywinrt |
|
||||
| PyInstaller | empaqueta `Labmagic.exe` | GPL 2.0 con excepción del *bootloader*: el ejecutable resultante puede distribuirse bajo la licencia del programa (MIT) | https://pyinstaller.org/ |
|
||||
| Fuentes Segoe UI y Consolas | tipografía de la interfaz | vienen con Windows; no se redistribuyen | Microsoft |
|
||||
| Blackmagic Camera Control Protocol | órdenes a la cámara por Bluetooth | protocolo publicado por Blackmagic Design en el manual de sus cámaras para implementaciones de terceros; no incluye código de Blackmagic | https://www.blackmagicdesign.com/ |
|
||||
|
||||
Ninguno de estos componentes obliga a publicar el código de Labmagic; aun así, se publica
|
||||
entero bajo MIT en https://git.labstreamsas.com/Labstream/labmagic.
|
||||
|
||||
Los textos completos de PSF, Tcl/Tk, MIT y GPL 2.0 con excepción se encuentran en los enlaces
|
||||
indicados y dentro del paquete de cada componente (`py -3.12 -m pip show -f bleak`, etc.).
|
||||
2457
app.py
Archivo normal
876
bmpcc_ble.py
Archivo normal
@@ -0,0 +1,876 @@
|
||||
"""
|
||||
Control de cámaras Blackmagic (Pocket Cinema Camera 4K/6K) por Bluetooth LE.
|
||||
|
||||
Implementa el "Blackmagic Camera Control Protocol" sobre el servicio BLE de la
|
||||
cámara. Toda la comunicación corre en un hilo propio con su bucle asyncio; la
|
||||
interfaz gráfica habla con él mediante BleWorker.submit() y recibe eventos por
|
||||
una cola (queue.Queue).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import platform
|
||||
import queue
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
|
||||
|
||||
def _run_powershell(command: str) -> str:
|
||||
"""Ejecuta un comando de PowerShell sin ventana y devuelve su salida en UTF-8."""
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
full = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; " + command
|
||||
res = subprocess.run(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command", full],
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
startupinfo=startupinfo,
|
||||
)
|
||||
return res.stdout.decode("utf-8", errors="replace")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UUIDs del servicio Blackmagic Camera Service
|
||||
# ---------------------------------------------------------------------------
|
||||
SERVICE_UUID = "291d567a-6d75-11e6-8b77-86f30ca893d3"
|
||||
CH_OUT = "5dd3465f-1aee-4299-8493-d2eca2f8e1bb" # Outgoing Camera Control (write)
|
||||
CH_IN = "b864e140-76a0-416a-bf30-5876504537d9" # Incoming Camera Control (indicate)
|
||||
CH_TIMECODE = "6d8f2110-86f1-41bf-9afb-451d87e976c8" # Timecode (notify)
|
||||
CH_STATUS = "7fe8691d-95dc-4fc5-8abd-ca74339b51b9" # Camera Status (read/write/notify)
|
||||
CH_DEVNAME = "ffac0c52-c9fb-41a0-b063-cc76282eb89c" # Device Name (write)
|
||||
CH_PROTO_PREFIX = "8f1fd018-b508-456f-8f82-" # Protocol Version (read); el sufijo cambia
|
||||
|
||||
# Tipos de dato del protocolo
|
||||
T_VOID, T_INT8, T_INT16, T_INT32, T_INT64, T_STRING, T_FIXED16 = 0, 1, 2, 3, 4, 5, 128
|
||||
# Operaciones
|
||||
OP_ASSIGN, OP_OFFSET, OP_REPORT = 0, 1, 2
|
||||
|
||||
# Nombres legibles de (categoría, parámetro) para el registro
|
||||
PARAM_NAMES = {
|
||||
(0, 0): "Foco",
|
||||
(0, 1): "Autofoco",
|
||||
(0, 2): "Apertura (AV)",
|
||||
(0, 3): "Apertura normalizada",
|
||||
(0, 4): "Apertura (ordinal)",
|
||||
(0, 5): "Auto iris",
|
||||
(0, 6): "Estabilización óptica",
|
||||
(0, 7): "Zoom (mm)",
|
||||
(0, 8): "Zoom normalizado",
|
||||
(0, 9): "Zoom continuo",
|
||||
(1, 0): "Modo de vídeo",
|
||||
(1, 1): "Ganancia (antigua)",
|
||||
(1, 2): "Balance de blancos",
|
||||
(1, 3): "Auto balance de blancos",
|
||||
(1, 4): "Restaurar auto WB",
|
||||
(1, 5): "Exposición (µs)",
|
||||
(1, 6): "Exposición (ordinal)",
|
||||
(1, 7): "Rango dinámico",
|
||||
(1, 8): "Nitidez",
|
||||
(1, 9): "Formato de grabación",
|
||||
(1, 10): "Modo de autoexposición",
|
||||
(1, 11): "Ángulo de obturador",
|
||||
(1, 12): "Velocidad de obturador",
|
||||
(1, 13): "Ganancia (dB)",
|
||||
(1, 14): "ISO",
|
||||
(1, 15): "LUT de monitor",
|
||||
(1, 16): "Filtro ND",
|
||||
(2, 0): "Nivel de micrófono",
|
||||
(2, 1): "Nivel de auriculares",
|
||||
(2, 3): "Nivel de altavoz",
|
||||
(2, 4): "Tipo de entrada de audio",
|
||||
(2, 5): "Niveles de entrada",
|
||||
(2, 6): "Alimentación phantom",
|
||||
(3, 0): "Overlays activos",
|
||||
(3, 3): "Overlays",
|
||||
(4, 0): "Brillo de pantalla",
|
||||
(4, 1): "Ayudas de exposición/enfoque",
|
||||
(4, 2): "Nivel de zebra",
|
||||
(4, 3): "Nivel de peaking",
|
||||
(4, 4): "Barras de color (s)",
|
||||
(4, 5): "Focus assist",
|
||||
(5, 0): "Brillo tally",
|
||||
(6, 0): "Fuente de referencia",
|
||||
(7, 0): "Reloj",
|
||||
(7, 1): "Idioma",
|
||||
(8, 0): "Color: lift",
|
||||
(8, 1): "Color: gamma",
|
||||
(8, 2): "Color: gain",
|
||||
(8, 3): "Color: offset",
|
||||
(8, 4): "Color: contraste",
|
||||
(8, 5): "Color: luma mix",
|
||||
(8, 6): "Color: tono/saturación",
|
||||
(8, 7): "Color: reset",
|
||||
(9, 0): "Alimentación (mV, %, ?)",
|
||||
(9, 4): "Timecode",
|
||||
(10, 0): "Códec",
|
||||
(10, 1): "Transporte",
|
||||
(12, 0): "Metadatos: reel",
|
||||
(12, 1): "Metadatos: etiquetas de escena",
|
||||
(12, 2): "Metadatos: escena",
|
||||
(12, 3): "Metadatos: toma",
|
||||
(12, 4): "Metadatos: toma buena",
|
||||
(12, 5): "Metadatos: id de cámara",
|
||||
(12, 6): "Metadatos: operador",
|
||||
(12, 7): "Metadatos: director",
|
||||
(12, 8): "Metadatos: proyecto",
|
||||
(12, 9): "Objetivo: tipo",
|
||||
(12, 10): "Objetivo: iris",
|
||||
(12, 11): "Objetivo: focal",
|
||||
(12, 12): "Objetivo: distancia",
|
||||
(12, 13): "Objetivo: filtro",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Codificación / decodificación
|
||||
# ---------------------------------------------------------------------------
|
||||
def enc_fixed16(value: float) -> bytes:
|
||||
"""fixed16 = 5.11 con signo (valor * 2048), little-endian."""
|
||||
v = int(round(value * 2048))
|
||||
v = max(-32768, min(32767, v))
|
||||
return struct.pack("<h", v)
|
||||
|
||||
|
||||
def build_packet(category: int, parameter: int, dtype: int, op: int, data: bytes = b"") -> bytes:
|
||||
"""Paquete del protocolo: cabecera de 4 bytes + comando 0 + datos, relleno a 4 bytes."""
|
||||
length = 4 + len(data)
|
||||
pkt = bytes([255, length, 0, 0, category, parameter, dtype, op]) + data
|
||||
pkt += b"\x00" * ((-len(pkt)) % 4)
|
||||
return pkt
|
||||
|
||||
|
||||
def decode_data(dtype: int, data: bytes):
|
||||
"""Convierte los bytes de datos a una lista de valores Python según el tipo."""
|
||||
try:
|
||||
if dtype == T_VOID:
|
||||
return list(data)
|
||||
if dtype == T_INT8:
|
||||
return list(struct.unpack(f"<{len(data)}b", data))
|
||||
if dtype == T_INT16:
|
||||
n = len(data) // 2
|
||||
return list(struct.unpack(f"<{n}h", data[: n * 2]))
|
||||
if dtype == T_INT32:
|
||||
n = len(data) // 4
|
||||
return list(struct.unpack(f"<{n}i", data[: n * 4]))
|
||||
if dtype == T_INT64:
|
||||
n = len(data) // 8
|
||||
return list(struct.unpack(f"<{n}q", data[: n * 8]))
|
||||
if dtype == T_STRING:
|
||||
return [data.decode("utf-8", errors="replace").rstrip("\x00")]
|
||||
if dtype == T_FIXED16:
|
||||
n = len(data) // 2
|
||||
return [v / 2048.0 for v in struct.unpack(f"<{n}h", data[: n * 2])]
|
||||
except struct.error:
|
||||
pass
|
||||
return list(data)
|
||||
|
||||
|
||||
def parse_packets(buf: bytes):
|
||||
"""Trocea una notificación en paquetes (cat, par, tipo, op, datos). Puede venir más de uno."""
|
||||
out = []
|
||||
i = 0
|
||||
n = len(buf)
|
||||
while i + 8 <= n:
|
||||
length = buf[i + 1]
|
||||
if length < 4:
|
||||
break
|
||||
cat, par, dtype, op = buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]
|
||||
data = bytes(buf[i + 8 : i + 4 + length])
|
||||
out.append((cat, par, dtype, op, data))
|
||||
i += 4 + length
|
||||
i += (-i) % 4
|
||||
return out
|
||||
|
||||
|
||||
def decode_timecode(data: bytes) -> str:
|
||||
"""Timecode BCD: bytes [FF, SS, MM, HH]; el bit alto de HH marca drop-frame."""
|
||||
if len(data) < 4:
|
||||
return "--:--:--:--"
|
||||
f, s, m, h = data[0], data[1], data[2], data[3]
|
||||
sep = ";" if h & 0x80 else ":"
|
||||
return f"{h & 0x7F:02x}:{m:02x}:{s:02x}{sep}{f:02x}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hilo con bucle asyncio
|
||||
# ---------------------------------------------------------------------------
|
||||
class BleWorker:
|
||||
"""Ejecuta un bucle asyncio en un hilo aparte para que Tk no se bloquee."""
|
||||
|
||||
def __init__(self):
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self._thread = threading.Thread(target=self._run, name="ble-loop", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self):
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_forever()
|
||||
|
||||
def submit(self, coro):
|
||||
"""Lanza una corrutina en el bucle y devuelve un concurrent.futures.Future."""
|
||||
return asyncio.run_coroutine_threadsafe(coro, self.loop)
|
||||
|
||||
def stop(self):
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cámara
|
||||
# ---------------------------------------------------------------------------
|
||||
class Camera:
|
||||
"""Una cámara Blackmagic por BLE. Los métodos async deben correr en el bucle del worker."""
|
||||
|
||||
def __init__(self, events: queue.Queue):
|
||||
self.events = events
|
||||
self.client = None
|
||||
self.device = None
|
||||
self.devices = {} # address -> BLEDevice (de la última búsqueda)
|
||||
self.state = {} # (cat, par) -> (dtype, data bytes, valores)
|
||||
self.protocol_version = ""
|
||||
self.power_on = None
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._proto_uuid = None
|
||||
self._payload_count = 0
|
||||
self._payload_event = None
|
||||
|
||||
# ----- eventos hacia la interfaz -------------------------------------
|
||||
def emit(self, kind: str, **payload):
|
||||
payload["kind"] = kind
|
||||
payload["t"] = time.time()
|
||||
self.events.put(payload)
|
||||
|
||||
def log(self, text: str):
|
||||
self.emit("log", text=text)
|
||||
|
||||
# ----- búsqueda y conexión ------------------------------------------
|
||||
async def scan(self, timeout: float = 5.0, emit_event: bool = True):
|
||||
self.log(f"Buscando cámaras ({timeout:.0f} s)...")
|
||||
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
|
||||
cams = []
|
||||
for address, (dev, adv) in found.items():
|
||||
uuids = [u.lower() for u in (adv.service_uuids or [])]
|
||||
name = dev.name or adv.local_name or ""
|
||||
if SERVICE_UUID in uuids or name.startswith("A:"):
|
||||
self.devices[address] = dev
|
||||
cams.append({"address": address, "name": name or "(sin nombre)", "rssi": adv.rssi})
|
||||
cams.sort(key=lambda c: -c["rssi"])
|
||||
if emit_event:
|
||||
self.emit("devices", devices=cams)
|
||||
self.log(f"Anunciándose ahora: {len(cams)} cámara(s).")
|
||||
return cams
|
||||
|
||||
async def paired_cameras(self):
|
||||
"""Cámaras Blackmagic ya emparejadas en Windows (aunque no se estén anunciando).
|
||||
|
||||
Se consulta el árbol PnP de Windows con PowerShell: cada dispositivo BLE emparejado
|
||||
aparece como BTHLE\\DEV_<MAC> con su nombre.
|
||||
"""
|
||||
cams = []
|
||||
try:
|
||||
cmd = (
|
||||
"Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | "
|
||||
"Where-Object { $_.InstanceId -like 'BTHLE\\DEV_*' } | "
|
||||
"Select-Object FriendlyName, InstanceId, Status | ConvertTo-Json -Compress"
|
||||
)
|
||||
out = await asyncio.to_thread(_run_powershell, cmd)
|
||||
if out.strip():
|
||||
items = json.loads(out)
|
||||
if isinstance(items, dict):
|
||||
items = [items]
|
||||
for it in items:
|
||||
name = (it.get("FriendlyName") or "").strip()
|
||||
inst = it.get("InstanceId") or ""
|
||||
if not (name.startswith("A:") or "blackmagic" in name.lower()):
|
||||
continue
|
||||
m = re.search(r"DEV_([0-9A-Fa-f]{12})", inst)
|
||||
if not m:
|
||||
continue
|
||||
mac = m.group(1).upper()
|
||||
addr = ":".join(mac[i : i + 2] for i in range(0, 12, 2))
|
||||
cams.append({"address": addr, "name": name, "rssi": None, "paired": True})
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"No se pudo consultar los dispositivos emparejados: {e}")
|
||||
return cams
|
||||
|
||||
async def find_cameras(self, timeout: float = 5.0):
|
||||
"""Une las emparejadas en Windows con las que se anuncian ahora mismo."""
|
||||
paired = await self.paired_cameras()
|
||||
seen = await self.scan(timeout, emit_event=False)
|
||||
by_addr = {c["address"].upper(): dict(c, paired=False) for c in seen}
|
||||
for c in paired:
|
||||
key = c["address"].upper()
|
||||
if key in by_addr:
|
||||
by_addr[key]["paired"] = True
|
||||
else:
|
||||
by_addr[key] = c
|
||||
cams = sorted(by_addr.values(), key=lambda c: (-(c["rssi"] or -999)))
|
||||
self.emit("devices", devices=cams)
|
||||
return cams
|
||||
|
||||
async def connect(self, address: str, name: str | None = None, fresh_retry: bool = False):
|
||||
if self.client and self.client.is_connected:
|
||||
await self.disconnect()
|
||||
self.emit("connecting", address=address)
|
||||
dev = self.devices.get(address)
|
||||
if dev is None:
|
||||
# En Windows basta la dirección: no hace falta que la cámara se esté anunciando
|
||||
# (una cámara ya conectada a nivel de sistema deja de anunciarse).
|
||||
from bleak.backends.device import BLEDevice
|
||||
|
||||
dev = BLEDevice(address, name, None)
|
||||
elif name and not dev.name:
|
||||
dev = type(dev)(dev.address, name, dev.details)
|
||||
self.device = dev
|
||||
self.state = {}
|
||||
got_payload = False
|
||||
for attempt in (1, 2):
|
||||
self.client = BleakClient(dev, disconnected_callback=self._on_disconnected, timeout=30.0)
|
||||
self.log(f"Conectando con {dev.name or address}..." if attempt == 1 else "Conectando de nuevo con enlace nuevo...")
|
||||
try:
|
||||
await self.client.connect()
|
||||
got_payload = await self._setup_after_connect(address, dev)
|
||||
except Exception:
|
||||
c, self.client = self.client, None
|
||||
try:
|
||||
await c.disconnect()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
raise
|
||||
if got_payload or not fresh_retry or attempt == 2:
|
||||
break
|
||||
# Sin volcado inicial: el enlace Bluetooth venía reutilizado. Lo cerramos y esperamos a
|
||||
# que Windows lo suelte para que la próxima conexión sea un enlace nuevo.
|
||||
self.log("La cámara no ha enviado su configuración (enlace Bluetooth reutilizado). "
|
||||
"Reinicio el enlace para forzarla...")
|
||||
self.emit("connecting", address=address, text="Reiniciando el enlace Bluetooth para leer la configuración...")
|
||||
c, self.client = self.client, None
|
||||
try:
|
||||
await c.disconnect()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if not await self._wait_link_down(address, 20.0):
|
||||
self.log("Windows mantiene el enlace abierto: suele ser la ventana de Configuración > "
|
||||
"Bluetooth. Ciérrala y vuelve a conectar para que lleguen todos los valores.")
|
||||
self.emit("link_held", address=address)
|
||||
self.emit(
|
||||
"connected",
|
||||
address=address,
|
||||
name=dev.name or address,
|
||||
protocol=self.protocol_version,
|
||||
mtu=self.client.mtu_size,
|
||||
payload=got_payload,
|
||||
)
|
||||
if got_payload:
|
||||
self.log(f"Conectada. Protocolo {self.protocol_version or '?'}. Configuración recibida.")
|
||||
else:
|
||||
self.log(f"Conectada. Protocolo {self.protocol_version or '?'}. Sin configuración inicial: los "
|
||||
"valores se irán rellenando según los toques.")
|
||||
|
||||
async def _wait_link_down(self, address: str, timeout: float) -> bool:
|
||||
"""Espera a que Windows suelte el enlace BLE con la cámara (connection_status = 0)."""
|
||||
try:
|
||||
from winrt.windows.devices.bluetooth import BluetoothLEDevice
|
||||
except Exception: # noqa: BLE001
|
||||
await asyncio.sleep(3.0)
|
||||
return True
|
||||
addr = int(address.replace(":", ""), 16)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
d = await BluetoothLEDevice.from_bluetooth_address_async(addr)
|
||||
status = d.connection_status if d is not None else 0
|
||||
if d is not None:
|
||||
d.close()
|
||||
if status == 0:
|
||||
await asyncio.sleep(0.5)
|
||||
return True
|
||||
await asyncio.sleep(1.0)
|
||||
return False
|
||||
|
||||
async def _setup_after_connect(self, address: str, dev) -> bool:
|
||||
|
||||
# Localizar la característica de versión de protocolo (el sufijo del UUID varía)
|
||||
self._proto_uuid = None
|
||||
found_service = False
|
||||
for svc in self.client.services:
|
||||
if svc.uuid.lower() != SERVICE_UUID:
|
||||
continue
|
||||
found_service = True
|
||||
for ch in svc.characteristics:
|
||||
if ch.uuid.lower().startswith(CH_PROTO_PREFIX):
|
||||
self._proto_uuid = ch.uuid
|
||||
if not found_service:
|
||||
# Windows oculta el servicio cuando no puede cifrar el enlace: la cámara ya no
|
||||
# reconoce el emparejamiento guardado (o nunca se emparejó).
|
||||
self.emit("needs_pairing", address=address)
|
||||
raise RuntimeError(
|
||||
"Acceso denegado al servicio de control de la cámara. Causas, por orden: (1) otra "
|
||||
"aplicación de este PC ya está conectada a la cámara (otra copia de Labmagic, un "
|
||||
"script de prueba o la ventana de Configuración > Bluetooth): ciérrala y vuelve a "
|
||||
"conectar; (2) la cámara ya no acepta el emparejamiento guardado: pulsa «Emparejar "
|
||||
"(PIN)» y escribe el PIN que muestre la cámara."
|
||||
)
|
||||
if self._proto_uuid:
|
||||
try:
|
||||
raw = await self.client.read_gatt_char(self._proto_uuid)
|
||||
self.protocol_version = raw.decode("utf-8", errors="replace").strip("\x00 ")
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.protocol_version = "?"
|
||||
self.log(f"No se pudo leer la versión de protocolo: {e}")
|
||||
|
||||
# Nombre con el que aparecemos en el menú Bluetooth de la cámara
|
||||
try:
|
||||
name = f"{platform.node()[:18]} (PC)"
|
||||
await self.client.write_gatt_char(CH_DEVNAME, name.encode("utf-8"), response=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"No se pudo enviar el nombre del equipo: {e}")
|
||||
|
||||
# Estado (encendida/apagada)
|
||||
try:
|
||||
st = await self.client.read_gatt_char(CH_STATUS)
|
||||
self._handle_status(bytes(st))
|
||||
except Exception as e: # noqa: BLE001
|
||||
msg = str(e).lower()
|
||||
if "auth" in msg or "access" in msg or "0x05" in msg or "0x0f" in msg:
|
||||
raise RuntimeError(
|
||||
"La cámara exige emparejamiento. Empareja desde Configuración de Windows > "
|
||||
"Bluetooth con el PIN que muestra la cámara y vuelve a conectar."
|
||||
) from e
|
||||
raise
|
||||
|
||||
self._payload_count = 0
|
||||
self._payload_event = asyncio.Event()
|
||||
await self.client.start_notify(CH_STATUS, lambda _c, d: self._handle_status(bytes(d)))
|
||||
await self.client.start_notify(CH_TIMECODE, lambda _c, d: self._handle_timecode(bytes(d)))
|
||||
await self.client.start_notify(CH_IN, lambda _c, d: self._handle_incoming(bytes(d)))
|
||||
|
||||
# Pedir el volcado completo de configuración. La cámara lo envía sola solo cuando el enlace
|
||||
# Bluetooth es nuevo; escribir "Camera Ready" (0x20) con "Initial Payload Received" (0x10) a
|
||||
# cero (y el bit de encendido) hace que lo reenvíe aunque Windows reutilice el enlace.
|
||||
try:
|
||||
await self.client.write_gatt_char(CH_STATUS, bytes([0x21]), response=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"No se pudo pedir la configuración a la cámara: {e}")
|
||||
# El volcado llega en varios bloques a lo largo de unos segundos; esperamos a tener una
|
||||
# cantidad razonable de parámetros (o 6 s) antes de dar la conexión por completa.
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 6.0 and self._payload_count < 8:
|
||||
await asyncio.sleep(0.2)
|
||||
return self._payload_count >= 8
|
||||
|
||||
async def disconnect(self):
|
||||
c = self.client
|
||||
self.client = None
|
||||
if c is not None:
|
||||
try:
|
||||
await c.disconnect()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f"Error al desconectar: {e}")
|
||||
self.emit("disconnected", reason="manual")
|
||||
|
||||
def _on_disconnected(self, client):
|
||||
# Solo cuenta si es el cliente actual (los avisos de clientes antiguos llegan con retraso)
|
||||
if self.client is not None and client is self.client:
|
||||
self.client = None
|
||||
self.log("La cámara se ha desconectado.")
|
||||
self.emit("disconnected", reason="remote")
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return bool(self.client and self.client.is_connected)
|
||||
|
||||
# ----- emparejamiento con PIN (sin pasar por Configuración de Windows) ----------
|
||||
async def pair(self, address: str, ask_pin, name: str | None = None):
|
||||
"""Quita el emparejamiento antiguo de Windows y empareja de nuevo con el PIN de la cámara.
|
||||
|
||||
ask_pin: función sin argumentos que se llama desde un hilo auxiliar y debe devolver el
|
||||
PIN escrito por el usuario (o None para cancelar). Puede bloquear mientras espera.
|
||||
Al terminar con éxito, conecta.
|
||||
"""
|
||||
from winrt.windows.devices.bluetooth import BluetoothLEDevice
|
||||
from winrt.windows.devices.enumeration import (
|
||||
DeviceInformation,
|
||||
DevicePairingKinds,
|
||||
DevicePairingProtectionLevel,
|
||||
)
|
||||
|
||||
if self.connected:
|
||||
await self.disconnect()
|
||||
self.emit("pairing", stage="start", address=address)
|
||||
dev = await BluetoothLEDevice.from_bluetooth_address_async(int(address.replace(":", ""), 16))
|
||||
if dev is None:
|
||||
raise RuntimeError("No se encuentra la cámara. ¿Está encendida y con el Bluetooth activado?")
|
||||
info = await DeviceInformation.create_from_id_async(dev.device_information.id)
|
||||
pairing = info.pairing
|
||||
if pairing.is_paired:
|
||||
self.log("Quitando el emparejamiento anterior de Windows...")
|
||||
res = await pairing.unpair_async()
|
||||
self.log(f"Emparejamiento anterior eliminado (estado {int(res.status)}).")
|
||||
await asyncio.sleep(1.5)
|
||||
info = await DeviceInformation.create_from_id_async(dev.device_information.id)
|
||||
pairing = info.pairing
|
||||
if not pairing.can_pair:
|
||||
raise RuntimeError("Windows indica que este dispositivo no se puede emparejar ahora mismo.")
|
||||
|
||||
custom = pairing.custom
|
||||
log = self.log
|
||||
|
||||
def handler(_sender, args):
|
||||
kind = int(args.pairing_kind)
|
||||
if kind == int(DevicePairingKinds.PROVIDE_PIN):
|
||||
deferral = args.get_deferral()
|
||||
|
||||
def work():
|
||||
try:
|
||||
pin = ask_pin()
|
||||
if pin:
|
||||
args.accept_with_pin(str(pin).strip())
|
||||
else:
|
||||
log("Emparejamiento cancelado por el usuario.")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"Error al aceptar el PIN: {e}")
|
||||
finally:
|
||||
deferral.complete()
|
||||
|
||||
threading.Thread(target=work, name="pin-prompt", daemon=True).start()
|
||||
elif kind == int(DevicePairingKinds.DISPLAY_PIN):
|
||||
log(f"La cámara pide este PIN: {args.pin}")
|
||||
args.accept()
|
||||
else:
|
||||
args.accept()
|
||||
|
||||
token = custom.add_pairing_requested(handler)
|
||||
try:
|
||||
try:
|
||||
kinds = DevicePairingKinds(
|
||||
int(DevicePairingKinds.PROVIDE_PIN)
|
||||
| int(DevicePairingKinds.CONFIRM_ONLY)
|
||||
| int(DevicePairingKinds.DISPLAY_PIN)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
kinds = DevicePairingKinds.PROVIDE_PIN
|
||||
self.log("Solicitando emparejamiento: mira la pantalla de la cámara, mostrará un PIN.")
|
||||
self.emit("pairing", stage="requested", address=address)
|
||||
result = await custom.pair_with_protection_level_async(
|
||||
kinds, DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION
|
||||
)
|
||||
finally:
|
||||
custom.remove_pairing_requested(token)
|
||||
|
||||
status = int(result.status)
|
||||
names = {
|
||||
0: "emparejada", 1: "la cámara no está lista para emparejar", 2: "no emparejada",
|
||||
3: "ya estaba emparejada", 4: "la cámara rechazó la conexión", 5: "demasiadas conexiones",
|
||||
6: "fallo de hardware", 7: "tiempo de espera agotado (no llegó el PIN a tiempo)",
|
||||
8: "autenticación no permitida", 9: "fallo de autenticación (PIN incorrecto)",
|
||||
10: "sin perfiles compatibles", 11: "no se alcanzó el nivel de protección",
|
||||
12: "acceso denegado", 13: "datos de ceremonia no válidos", 14: "emparejamiento cancelado",
|
||||
15: "ya hay una operación en curso", 16: "falta el manejador de PIN",
|
||||
17: "rechazado por el manejador", 18: "el dispositivo ya tiene asociación", 19: "fallo",
|
||||
}
|
||||
if status not in (0, 3):
|
||||
self.emit("pairing", stage="failed", address=address, status=status)
|
||||
raise RuntimeError(f"No se pudo emparejar: {names.get(status, status)} (código {status}).")
|
||||
self.log("Cámara emparejada con Windows. Conectando...")
|
||||
self.emit("pairing", stage="done", address=address)
|
||||
await asyncio.sleep(1.0)
|
||||
await self.connect(address, name)
|
||||
|
||||
# ----- notificaciones -----------------------------------------------
|
||||
def _handle_status(self, data: bytes):
|
||||
if not data:
|
||||
return
|
||||
self.power_on = bool(data[0] & 0x01)
|
||||
self.emit("status", raw=data.hex(), power_on=self.power_on)
|
||||
|
||||
def _handle_timecode(self, data: bytes):
|
||||
pk = parse_packets(data)
|
||||
if pk:
|
||||
tc = decode_timecode(pk[-1][4])
|
||||
elif len(data) >= 4:
|
||||
tc = decode_timecode(data[-4:])
|
||||
else:
|
||||
return
|
||||
self.emit("timecode", timecode=tc)
|
||||
|
||||
def _handle_incoming(self, data: bytes):
|
||||
for cat, par, dtype, op, raw in parse_packets(data):
|
||||
if cat == 255 and par == 255:
|
||||
# Fin de bloque del volcado inicial
|
||||
if self._payload_event is not None:
|
||||
self._payload_event.set()
|
||||
continue
|
||||
values = decode_data(dtype, raw)
|
||||
self.state[(cat, par)] = (dtype, raw, values)
|
||||
if cat != 9:
|
||||
self._payload_count += 1
|
||||
self.emit("param", cat=cat, par=par, dtype=dtype, op=op, raw=raw, values=values)
|
||||
|
||||
# ----- envío ----------------------------------------------------------
|
||||
async def send(self, category: int, parameter: int, dtype: int, op: int, data: bytes = b""):
|
||||
if not self.connected:
|
||||
raise RuntimeError("No hay cámara conectada.")
|
||||
pkt = build_packet(category, parameter, dtype, op, data)
|
||||
async with self._send_lock:
|
||||
await self.client.write_gatt_char(CH_OUT, pkt, response=True)
|
||||
self.emit("sent", cat=category, par=parameter, dtype=dtype, op=op, raw=data)
|
||||
|
||||
# --- Objetivo --------------------------------------------------------
|
||||
async def autofocus(self):
|
||||
await self.send(0, 1, T_VOID, OP_ASSIGN)
|
||||
|
||||
async def set_focus(self, value: float):
|
||||
await self.send(0, 0, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, value))))
|
||||
|
||||
async def offset_focus(self, delta: float):
|
||||
await self.send(0, 0, T_FIXED16, OP_OFFSET, enc_fixed16(delta))
|
||||
|
||||
async def auto_iris(self):
|
||||
await self.send(0, 5, T_VOID, OP_ASSIGN)
|
||||
|
||||
async def set_aperture_norm(self, value: float):
|
||||
await self.send(0, 3, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, value))))
|
||||
|
||||
async def set_aperture_fstop(self, fnumber: float):
|
||||
"""Apertura en f/: el protocolo usa el Aperture Value, AV = 2·log2(f)."""
|
||||
av = 2.0 * math.log2(max(0.7, float(fnumber)))
|
||||
await self.send(0, 2, T_FIXED16, OP_ASSIGN, enc_fixed16(av))
|
||||
|
||||
async def offset_aperture_norm(self, delta: float):
|
||||
await self.send(0, 3, T_FIXED16, OP_OFFSET, enc_fixed16(delta))
|
||||
|
||||
async def set_zoom_norm(self, value: float):
|
||||
await self.send(0, 8, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, value))))
|
||||
|
||||
async def set_ois(self, enabled: bool):
|
||||
await self.send(0, 6, T_VOID, OP_ASSIGN, bytes([1 if enabled else 0]))
|
||||
|
||||
# --- Vídeo / exposición ---------------------------------------------
|
||||
async def set_iso(self, iso: int):
|
||||
await self.send(1, 14, T_INT32, OP_ASSIGN, struct.pack("<i", int(iso)))
|
||||
|
||||
async def set_shutter_angle(self, degrees: float):
|
||||
await self.send(1, 11, T_INT32, OP_ASSIGN, struct.pack("<i", int(round(degrees * 100))))
|
||||
|
||||
async def set_shutter_speed(self, denominator: int):
|
||||
await self.send(1, 12, T_INT32, OP_ASSIGN, struct.pack("<i", int(denominator)))
|
||||
|
||||
async def set_white_balance(self, kelvin: int, tint: int):
|
||||
kelvin = max(2500, min(10000, int(kelvin)))
|
||||
tint = max(-50, min(50, int(tint)))
|
||||
await self.send(1, 2, T_INT16, OP_ASSIGN, struct.pack("<hh", kelvin, tint))
|
||||
|
||||
async def auto_white_balance(self):
|
||||
await self.send(1, 3, T_VOID, OP_ASSIGN)
|
||||
|
||||
async def set_dynamic_range(self, mode: int):
|
||||
await self.send(1, 7, T_INT8, OP_ASSIGN, bytes([mode & 0xFF]))
|
||||
|
||||
async def set_auto_exposure_mode(self, mode: int):
|
||||
await self.send(1, 10, T_INT8, OP_ASSIGN, bytes([mode & 0xFF]))
|
||||
|
||||
async def set_display_lut(self, lut: int, enabled: bool):
|
||||
await self.send(1, 15, T_INT8, OP_ASSIGN, bytes([lut & 0xFF, 1 if enabled else 0]))
|
||||
|
||||
async def set_gain_db(self, db: int):
|
||||
await self.send(1, 13, T_INT8, OP_ASSIGN, struct.pack("<b", max(-128, min(127, int(db)))))
|
||||
|
||||
# --- Grabación -------------------------------------------------------
|
||||
async def set_recording(self, recording: bool):
|
||||
cur = self.state.get((10, 1))
|
||||
if cur and len(cur[1]) >= 5:
|
||||
data = bytearray(cur[1])
|
||||
else:
|
||||
data = bytearray([0, 0, 0, 0, 0])
|
||||
data[0] = 2 if recording else 0 # 0 = preview, 1 = play, 2 = record
|
||||
data[1] = 0 # velocidad: 0 = pausa / grabación normal
|
||||
await self.send(10, 1, T_INT8, OP_ASSIGN, bytes(data))
|
||||
|
||||
# --- Monitor ---------------------------------------------------------
|
||||
# Pantallas destino (segundo uint16 de 3.0 Overlay enables y 4.1 Exposure and focus tools)
|
||||
OUT_LCD, OUT_HDMI, OUT_EVF, OUT_SDI_MAIN, OUT_SDI_FRONT = 1, 2, 4, 8, 16
|
||||
|
||||
async def set_tools(self, zebra: bool, focus_assist: bool, false_colour: bool, targets: int = 3):
|
||||
"""4.1: [bits zebra 0x1, focus assist 0x2, falso color 0x4] + [pantallas destino]."""
|
||||
bits = (1 if zebra else 0) | (2 if focus_assist else 0) | (4 if false_colour else 0)
|
||||
await self.send(4, 1, T_INT16, OP_ASSIGN, struct.pack("<HH", bits, targets & 0xFFFF))
|
||||
|
||||
async def set_overlays(self, status: bool, frame_guides: bool, clean_feed: bool, targets: int = 3):
|
||||
"""3.0: [bits texto de estado 0x1, guías 0x2, señal limpia 0x4] + [pantallas destino]."""
|
||||
bits = (1 if status else 0) | (2 if frame_guides else 0) | (4 if clean_feed else 0)
|
||||
await self.send(3, 0, T_INT16, OP_ASSIGN, struct.pack("<HH", bits, targets & 0xFFFF))
|
||||
|
||||
async def set_focus_assist(self, method: int, colour: int):
|
||||
"""4.5 Focus assist: método 0 = peak, 1 = líneas de color; color 0 rojo, 1 verde, 2 azul, 3 blanco, 4 negro."""
|
||||
await self.send(4, 5, T_INT8, OP_ASSIGN, bytes([method & 0xFF, colour & 0xFF]))
|
||||
|
||||
async def set_peaking(self, enabled: bool, colour: int, zebra: bool = False, false_colour: bool = False,
|
||||
targets: int = 3):
|
||||
"""Activa el focus assist en modo líneas de color y lo enciende/apaga en las ayudas (4.1)."""
|
||||
if enabled:
|
||||
await self.set_focus_assist(1, colour)
|
||||
await self.set_tools(zebra, enabled, false_colour, targets)
|
||||
|
||||
# --- Formato de grabación -------------------------------------------
|
||||
async def set_recording_format(self, file_fps: int, sensor_fps: int, width: int, height: int, *,
|
||||
m_rate: bool = False, sensor_m_rate: bool = False, off_speed: bool = False,
|
||||
windowed: bool = False, interlaced: bool = False):
|
||||
"""1.9: [fps archivo, fps sensor (0 = sin cambio), ancho, alto, flags]."""
|
||||
flags = ((1 if m_rate else 0) | (2 if sensor_m_rate else 0) | (4 if off_speed else 0)
|
||||
| (8 if interlaced else 0) | (16 if windowed else 0))
|
||||
await self.send(1, 9, T_INT16, OP_ASSIGN,
|
||||
struct.pack("<hhhhh", int(file_fps), int(sensor_fps), int(width), int(height), flags))
|
||||
|
||||
async def set_codec(self, basic: int, variant: int):
|
||||
"""10.0: códec básico (2 ProRes, 3 Blackmagic RAW) y variante."""
|
||||
await self.send(10, 0, T_INT8, OP_ASSIGN, bytes([basic & 0xFF, variant & 0xFF]))
|
||||
|
||||
async def set_zebra_level(self, value: float):
|
||||
await self.send(4, 2, T_FIXED16, OP_ASSIGN, enc_fixed16(value))
|
||||
|
||||
async def set_peaking_level(self, value: float):
|
||||
await self.send(4, 3, T_FIXED16, OP_ASSIGN, enc_fixed16(value))
|
||||
|
||||
async def show_colour_bars(self, seconds: int):
|
||||
await self.send(4, 4, T_INT8, OP_ASSIGN, bytes([max(0, min(30, int(seconds)))]))
|
||||
|
||||
# --- Color -----------------------------------------------------------
|
||||
async def reset_colour_correction(self):
|
||||
await self.send(8, 7, T_VOID, OP_ASSIGN)
|
||||
|
||||
# --- Objetivo (resto) --------------------------------------------------
|
||||
async def set_zoom_mm(self, mm: int):
|
||||
await self.send(0, 7, T_INT16, OP_ASSIGN, struct.pack("<h", int(mm)))
|
||||
|
||||
async def set_zoom_speed(self, speed: float):
|
||||
"""0.9 zoom continuo: -1 (wide rápido) .. 0 (parar) .. +1 (tele rápido)."""
|
||||
await self.send(0, 9, T_FIXED16, OP_ASSIGN, enc_fixed16(max(-1.0, min(1.0, speed))))
|
||||
|
||||
# --- Vídeo (resto) -----------------------------------------------------
|
||||
async def set_exposure_us(self, us: int):
|
||||
await self.send(1, 5, T_INT32, OP_ASSIGN, struct.pack("<i", max(1, min(42000, int(us)))))
|
||||
|
||||
async def set_sharpening(self, level: int):
|
||||
"""1.8: 0 off, 1 bajo, 2 medio, 3 alto."""
|
||||
await self.send(1, 8, T_INT8, OP_ASSIGN, bytes([max(0, min(3, int(level)))]))
|
||||
|
||||
# --- Audio -------------------------------------------------------------
|
||||
async def set_mic_level(self, v: float):
|
||||
await self.send(2, 0, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
|
||||
|
||||
async def set_headphone_level(self, v: float):
|
||||
await self.send(2, 1, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
|
||||
|
||||
async def set_headphone_mix(self, v: float):
|
||||
await self.send(2, 2, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
|
||||
|
||||
async def set_speaker_level(self, v: float):
|
||||
await self.send(2, 3, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
|
||||
|
||||
async def set_audio_input_type(self, kind: int):
|
||||
"""2.4: 0 micro interno, 1 línea, 2 micro nivel bajo, 3 micro nivel alto."""
|
||||
await self.send(2, 4, T_INT8, OP_ASSIGN, bytes([max(0, min(3, int(kind)))]))
|
||||
|
||||
async def set_input_levels(self, ch0: float, ch1: float):
|
||||
await self.send(2, 5, T_FIXED16, OP_ASSIGN,
|
||||
enc_fixed16(max(0.0, min(1.0, ch0))) + enc_fixed16(max(0.0, min(1.0, ch1))))
|
||||
|
||||
async def set_phantom_power(self, on: bool):
|
||||
await self.send(2, 6, T_VOID, OP_ASSIGN, bytes([1 if on else 0]))
|
||||
|
||||
# --- Salida: guías y rejilla (3.3) ------------------------------------
|
||||
async def set_frame_guides(self, style: int, opacity: int, safe_area: int, grid_bits: int):
|
||||
"""3.3: estilo 0 off, 1 2.4:1, 2 2.39:1, 3 2.35:1, 4 1.85:1, 5 16:9, 6 14:9, 7 4:3, 8 2:1, 9 4:5, 10 1:1;
|
||||
opacidad 0-100; área segura 0-100 (0 = off); rejilla bits 1 tercios, 2 cruz, 4 punto, 8 horizonte."""
|
||||
await self.send(3, 3, T_INT8, OP_ASSIGN,
|
||||
bytes([style & 0xFF, max(0, min(100, int(opacity))), max(0, min(100, int(safe_area))),
|
||||
grid_bits & 0x0F]))
|
||||
|
||||
# --- Pantalla (resto) --------------------------------------------------
|
||||
async def set_display_brightness(self, v: float):
|
||||
await self.send(4, 0, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
|
||||
|
||||
async def set_program_return(self, seconds: int):
|
||||
await self.send(4, 6, T_INT8, OP_ASSIGN, bytes([max(0, min(30, int(seconds)))]))
|
||||
|
||||
async def set_timecode_source(self, source: int):
|
||||
"""4.7: 0 = clip, 1 = timecode."""
|
||||
await self.send(4, 7, T_INT8, OP_ASSIGN, bytes([1 if source else 0]))
|
||||
|
||||
# --- Tally -------------------------------------------------------------
|
||||
async def set_tally(self, v: float, which: int = 0):
|
||||
"""which: 0 ambos (5.0), 1 frontal (5.1), 2 trasero (5.2)."""
|
||||
await self.send(5, which, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
|
||||
|
||||
# --- Configuración: reloj -----------------------------------------------
|
||||
async def sync_clock(self):
|
||||
"""7.0 reloj en BCD (hora UTC HHMMSSFF, fecha YYYYMMDD) y 7.2 zona horaria en minutos."""
|
||||
now = time.gmtime()
|
||||
t_bcd = int(time.strftime("%H%M%S", now) + "00", 16)
|
||||
d_bcd = int(time.strftime("%Y%m%d", now), 16)
|
||||
await self.send(7, 0, T_INT32, OP_ASSIGN, struct.pack("<ii", t_bcd, d_bcd))
|
||||
offset_min = -time.timezone // 60 if not time.localtime().tm_isdst else -time.altzone // 60
|
||||
await self.send(7, 2, T_INT32, OP_ASSIGN, struct.pack("<i", int(offset_min)))
|
||||
|
||||
# --- Corrección de color -----------------------------------------------
|
||||
async def set_colour_rgby(self, parameter: int, values):
|
||||
"""8.0 lift, 8.1 gamma, 8.2 gain, 8.3 offset: [rojo, verde, azul, luma]."""
|
||||
data = b"".join(enc_fixed16(float(v)) for v in list(values)[:4])
|
||||
await self.send(8, parameter, T_FIXED16, OP_ASSIGN, data)
|
||||
|
||||
async def set_contrast(self, pivot: float, adjust: float):
|
||||
await self.send(8, 4, T_FIXED16, OP_ASSIGN, enc_fixed16(pivot) + enc_fixed16(adjust))
|
||||
|
||||
async def set_luma_mix(self, v: float):
|
||||
await self.send(8, 5, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
|
||||
|
||||
async def set_hue_sat(self, hue: float, sat: float):
|
||||
await self.send(8, 6, T_FIXED16, OP_ASSIGN, enc_fixed16(hue) + enc_fixed16(sat))
|
||||
|
||||
# --- Reproducción (10.1 modo 1 y 10.2) ---------------------------------
|
||||
async def set_transport(self, mode: int, speed: int = 0, loop: bool = False, play_all: bool = False):
|
||||
"""mode 0 preview, 1 play, 2 record; speed -n..n; conserva los bytes de ranuras de la cámara."""
|
||||
cur = self.state.get((10, 1))
|
||||
data = bytearray(cur[1]) if cur and len(cur[1]) >= 5 else bytearray([0, 0, 0, 0, 0])
|
||||
data[0] = mode & 0xFF
|
||||
data[1] = struct.pack("<b", max(-8, min(8, int(speed))))[0]
|
||||
flags = data[2] & ~0x03
|
||||
data[2] = flags | (1 if loop else 0) | (2 if play_all else 0)
|
||||
await self.send(10, 1, T_INT8, OP_ASSIGN, bytes(data))
|
||||
|
||||
async def playback_clip(self, next_clip: bool):
|
||||
await self.send(10, 2, T_INT8, OP_ASSIGN, bytes([1 if next_clip else 0]))
|
||||
|
||||
# --- Pizarra / metadatos (12) --------------------------------------------
|
||||
async def set_reel(self, reel: int):
|
||||
await self.send(12, 0, T_INT16, OP_ASSIGN, struct.pack("<h", max(0, min(999, int(reel)))))
|
||||
|
||||
async def set_scene_tags(self, tag: int, exterior: int, day: int):
|
||||
"""tag -1 ninguna, 0 WS, 1 CU, 2 MS, 3 BCU, 4 MCU, 5 ECU; exterior 0 int/1 ext; day 0 noche/1 día."""
|
||||
await self.send(12, 1, T_INT8, OP_ASSIGN, struct.pack("<bbb", int(tag), int(exterior), int(day)))
|
||||
|
||||
async def set_scene(self, text: str):
|
||||
await self.send(12, 2, T_STRING, OP_ASSIGN, text.encode("utf-8")[:5])
|
||||
|
||||
async def set_take(self, number: int, tag: int):
|
||||
"""tag -1 ninguna, 0 PU, 1 VFX, 2 SER."""
|
||||
await self.send(12, 3, T_INT8, OP_ASSIGN, struct.pack("<bb", max(1, min(99, int(number))), int(tag)))
|
||||
|
||||
async def set_good_take(self, good: bool):
|
||||
await self.send(12, 4, T_INT8, OP_ASSIGN, bytes([1 if good else 0]))
|
||||
|
||||
async def set_metadata_text(self, parameter: int, text: str, limit: int = 28):
|
||||
"""12.5 id cámara, 12.6 operador, 12.7 director, 12.8 proyecto."""
|
||||
await self.send(12, parameter, T_STRING, OP_ASSIGN, text.encode("utf-8")[:limit])
|
||||
|
||||
async def set_slate_mode(self, playback: bool):
|
||||
await self.send(12, 14, T_INT8, OP_ASSIGN, bytes([1 if playback else 0]))
|
||||
|
||||
# --- Alimentación ----------------------------------------------------
|
||||
async def power_off(self):
|
||||
if not self.connected:
|
||||
raise RuntimeError("No hay cámara conectada.")
|
||||
await self.client.write_gatt_char(CH_STATUS, bytes([0x00]), response=True)
|
||||
370
dock.html
Archivo normal
@@ -0,0 +1,370 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Labmagic</title>
|
||||
<link rel="icon" href="/icon.png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0d; --hud: #000; --card: #181818; --border: #2a2a2a; --btn: #2b2b2b; --btn-hover: #3c3c3c;
|
||||
--trough: #303030; --knob: #ececec; --fg: #f3f3f3; --muted: #9b9b9b; --dim: #555; --rec: #e3262c;
|
||||
--on: #3ad66c; --busy: #f0a500; --off: #4a4a4a; --sheet: #161616;
|
||||
}
|
||||
* { box-sizing: border-box; -webkit-user-select: none; user-select: none; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg);
|
||||
font: 13px "Segoe UI", system-ui, sans-serif; overflow-x: hidden; }
|
||||
button { font: inherit; color: var(--fg); background: var(--btn); border: 1px solid transparent; border-radius: 6px;
|
||||
padding: 7px 10px; cursor: pointer; }
|
||||
button:hover { background: var(--btn-hover); }
|
||||
button:active { background: #555; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
button.on { border-color: var(--rec); background: #2a1416; }
|
||||
button.big { font-size: 16px; font-weight: 600; padding: 12px 10px; letter-spacing: .5px; }
|
||||
button.sq { padding: 7px 0; width: 30px; }
|
||||
|
||||
/* HUD */
|
||||
.hud { background: var(--hud); padding: 7px 10px 6px; }
|
||||
.top { display: flex; align-items: center; gap: 8px; }
|
||||
.led { width: 11px; height: 11px; border-radius: 50%; background: var(--off); flex: none; }
|
||||
.led.on { background: var(--on); box-shadow: 0 0 6px var(--on); }
|
||||
.led.busy { background: var(--busy); }
|
||||
.name { font-weight: 600; font-size: 14px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.name:hover { text-decoration: underline; }
|
||||
.tc { margin-left: auto; font: 600 22px "Consolas", "Segoe UI", monospace; letter-spacing: 1px; white-space: nowrap; }
|
||||
.tc.rec { color: var(--rec); }
|
||||
.dot { color: transparent; font-size: 12px; width: 10px; }
|
||||
.dot.rec { color: var(--rec); animation: blink 1s steps(1) infinite; }
|
||||
@keyframes blink { 50% { color: transparent; } }
|
||||
.status { color: var(--muted); font-size: 11px; margin-top: 1px; display: flex; gap: 8px; }
|
||||
.status .pw { margin-left: auto; }
|
||||
.cells { display: grid; grid-template-columns: repeat(6, 1fr); gap: 3px; margin-top: 6px; }
|
||||
@media (max-width: 330px) { .cells { grid-template-columns: repeat(3, 1fr); } }
|
||||
.cell { background: #0a0a0a; border: 1px solid #1c1c1c; border-radius: 6px; padding: 4px 2px 5px; text-align: center; }
|
||||
.cell .l { color: var(--muted); font-size: 9px; letter-spacing: .8px; }
|
||||
.cell .v { font: 600 15px "Segoe UI", sans-serif; margin-top: 1px; white-space: nowrap; overflow: hidden; }
|
||||
.cell.click { cursor: pointer; }
|
||||
.cell.click .v { border-bottom: 1px solid #3a3a3a; display: inline-block; padding: 0 2px; }
|
||||
.cell.click:hover { background: #151515; }
|
||||
.cell.click:hover .v { border-bottom-color: var(--fg); }
|
||||
|
||||
.banner { display: none; padding: 6px 10px; font-size: 12px; background: var(--rec); }
|
||||
.banner.warn { background: #8a5a00; }
|
||||
.banner.ok { background: #1f4d2b; }
|
||||
.banner.show { display: block; }
|
||||
|
||||
main { padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.row { display: flex; gap: 6px; align-items: stretch; }
|
||||
.row > .grow { flex: 1; }
|
||||
.tog { font-size: 11.5px; padding: 6px 8px; flex: 1; white-space: nowrap; }
|
||||
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; vertical-align: -1px; margin-right: 4px;
|
||||
border: 1px solid #666; }
|
||||
|
||||
.srow { display: grid; grid-template-columns: 44px 30px 1fr 30px; gap: 5px; align-items: center; }
|
||||
.srow .lab { font-size: 12px; color: var(--muted); }
|
||||
.srow .lab b { display: block; color: var(--fg); font-weight: 600; font-size: 12px; white-space: nowrap; }
|
||||
input[type=range] { -webkit-appearance: none; width: 100%; height: 28px; background: transparent; margin: 0; cursor: pointer; }
|
||||
input[type=range]:disabled { opacity: .4; cursor: default; }
|
||||
input[type=range]::-webkit-slider-runnable-track { height: 6px; background: var(--trough); border-radius: 3px; }
|
||||
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
|
||||
background: var(--knob); margin-top: -6px; box-shadow: 0 1px 3px #000; }
|
||||
.rec-row { display: flex; align-items: center; gap: 12px; background: var(--card); border-radius: 8px; padding: 8px 10px; }
|
||||
.recbtn { width: 46px; height: 46px; border-radius: 50%; background: #1a1a1a; border: 3px solid #444; position: relative;
|
||||
flex: none; padding: 0; }
|
||||
.recbtn::after { content: ""; position: absolute; inset: 7px; border-radius: 50%; background: var(--rec); transition: inset .15s, border-radius .15s; }
|
||||
.recbtn:hover { border-color: #777; }
|
||||
.recbtn.rec::after { inset: 12px; border-radius: 4px; }
|
||||
.recinfo .s { font-weight: 700; letter-spacing: 1px; }
|
||||
.recinfo .s.rec { color: var(--rec); }
|
||||
.recinfo .i { color: var(--muted); font-size: 11px; margin-top: 2px; }
|
||||
.foot { display: flex; gap: 8px; color: var(--dim); font-size: 10.5px; align-items: center; }
|
||||
.foot a { color: var(--muted); cursor: pointer; margin-left: auto; white-space: nowrap; }
|
||||
.foot a:hover { color: var(--fg); }
|
||||
|
||||
/* Selector estilo cámara (hoja inferior) */
|
||||
.veil { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: none; }
|
||||
.veil.show { display: block; }
|
||||
.sheet { position: fixed; left: 0; right: 0; bottom: 0; background: var(--sheet); border-top: 1px solid var(--border);
|
||||
border-radius: 12px 12px 0 0; padding: 10px 12px 12px; transform: translateY(100%); transition: transform .18s ease-out;
|
||||
max-height: 85%; overflow-y: auto; }
|
||||
.sheet.show { transform: none; }
|
||||
.sh-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
|
||||
.sh-head .t { color: var(--muted); font-size: 11px; letter-spacing: 1px; }
|
||||
.sh-head .cur { font: 600 20px "Segoe UI", sans-serif; }
|
||||
.sh-head .x { margin-left: auto; color: var(--muted); cursor: pointer; padding: 0 4px; font-size: 16px; }
|
||||
.sh-head .x:hover { color: var(--fg); }
|
||||
.chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(54px, 1fr)); gap: 4px; margin-bottom: 6px; }
|
||||
.chip { background: #222; border: 1px solid transparent; border-radius: 6px; padding: 7px 2px; text-align: center;
|
||||
font-weight: 600; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
||||
.chip:hover { background: #333; }
|
||||
.chip.dim { color: var(--muted); font-weight: 400; background: #1c1c1c; }
|
||||
.chip.sel { background: var(--fg); color: #000; }
|
||||
.chip.wide { grid-column: span 2; }
|
||||
.sub { color: var(--muted); font-size: 10.5px; letter-spacing: .8px; margin: 6px 0 3px; }
|
||||
.note { color: var(--dim); font-size: 10.5px; margin-top: 4px; }
|
||||
.kv { display: grid; grid-template-columns: 36px 1fr 36px; gap: 6px; align-items: center; font-size: 10.5px; color: var(--muted); }
|
||||
.ticks { display: flex; justify-content: space-between; padding: 0 42px; color: var(--dim); font-size: 9px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hud">
|
||||
<div class="top">
|
||||
<span class="led" id="led"></span>
|
||||
<span class="name" id="name" title="Pulsa para conectar o desconectar">Sin cámara</span>
|
||||
<span class="tc" id="tc">00:00:00:00</span><span class="dot" id="dot">●</span>
|
||||
</div>
|
||||
<div class="status"><span id="status">Buscando Labmagic…</span><span class="pw" id="power"></span></div>
|
||||
<div class="cells">
|
||||
<div class="cell"><div class="l">FPS</div><div class="v" id="c_fps">—</div></div>
|
||||
<div class="cell click" data-pick="shutter"><div class="l">SHUTTER</div><div class="v" id="c_sh">—</div></div>
|
||||
<div class="cell click" data-pick="iris"><div class="l">IRIS</div><div class="v" id="c_iris">—</div></div>
|
||||
<div class="cell click" data-pick="iso"><div class="l">ISO</div><div class="v" id="c_iso">—</div></div>
|
||||
<div class="cell click" data-pick="wb"><div class="l">WB</div><div class="v" id="c_wb">—</div></div>
|
||||
<div class="cell click" data-pick="wb"><div class="l">TINT</div><div class="v" id="c_tint">—</div></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="banner" id="banner"></div>
|
||||
<main>
|
||||
<div class="row">
|
||||
<button class="big grow" id="af" title="Autofoco (también con la barra espaciadora)">AUTOFOCO (AF)</button>
|
||||
<button class="tog" id="peak" style="flex:0 0 auto" title="Focus assist con líneas de color en la salida elegida en Labmagic"><span class="swatch" id="peaksw"></span>PEAK</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="tog" id="zebra" title="Zebra en la salida elegida">ZEBRA</button>
|
||||
<button class="tog" id="false" title="Falso color en la salida elegida">FALSO COLOR</button>
|
||||
<button class="tog" id="afauto" title="Vuelve a enfocar cada pocos segundos (intervalo en Labmagic → Ajustes)">AF AUTO</button>
|
||||
</div>
|
||||
<div class="srow">
|
||||
<div class="lab">Foco<b id="focus_txt">—</b></div>
|
||||
<button class="sq" id="f_minus" title="Más cerca (Mayús: paso grande)">◀</button>
|
||||
<input type="range" id="focus" min="0" max="1000" value="500" disabled>
|
||||
<button class="sq" id="f_plus" title="Más lejos (Mayús: paso grande)">▶</button>
|
||||
</div>
|
||||
<div class="srow">
|
||||
<div class="lab">Iris<b id="iris_txt">—</b></div>
|
||||
<button class="sq" id="i_minus" title="Abrir">◀</button>
|
||||
<input type="range" id="iris" min="0" max="1000" value="0" disabled>
|
||||
<button class="sq" id="i_plus" title="Cerrar">▶</button>
|
||||
</div>
|
||||
<div class="rec-row">
|
||||
<button class="recbtn" id="rec" title="Grabar / parar"></button>
|
||||
<div class="recinfo"><div class="s" id="recstate">GRABAR</div><div class="i" id="recinfo">—</div></div>
|
||||
</div>
|
||||
<div class="foot"><span id="lens"></span><a id="open" title="Trae la ventana de Labmagic al frente">abrir Labmagic ⇱</a></div>
|
||||
</main>
|
||||
<div class="veil" id="veil"></div>
|
||||
<div class="sheet" id="sheet"></div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const ISO_FULL = [100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600];
|
||||
const ISO_ALL = [100,125,160,200,250,320,400,500,640,800,1000,1250,1600,2000,2500,3200,4000,5000,6400,8000,10000,12800,16000,20000,25600];
|
||||
const ANG_MAIN = [45, 90, 172.8, 180, 270, 360];
|
||||
const ANG_ALL = [11.2,15,22.5,30,37.5,45,60,72,90,108,120,144,150,172.8,180,216,270,360];
|
||||
const SPEEDS = [24,25,30,48,50,60,100,120,125,200,250,500,1000,2000];
|
||||
const FSTOPS = [1.4,1.8,2.0,2.8,3.5,4.0,4.5,5.6,6.3,8.0,11,16,22];
|
||||
const WB_CHIPS = [["☀ Día",5600,10],["💡 Tungst.",3200,0],["▭ Fluor.",4000,15],["☁ Nublado",6500,10],["⌂ Sombra",7500,10]];
|
||||
const PEAK_COL = ["#e3262c","#3ad66c","#3b82f6","#f3f3f3","#000"];
|
||||
const PEAK_NAME = ["Rojo","Verde","Azul","Blanco","Negro"];
|
||||
|
||||
let S = {}; // último estado recibido
|
||||
let dragging = null; // deslizador que se está arrastrando ("focus" | "iris" | "wb" | "tint")
|
||||
let bannerT = null;
|
||||
let alive = false;
|
||||
|
||||
const cmd = (c, extra = {}) =>
|
||||
fetch("/cmd", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cmd: c, ...extra }) })
|
||||
.catch(() => {});
|
||||
|
||||
// Envío con límite de ritmo para los deslizadores
|
||||
const pending = {};
|
||||
let flushT = null;
|
||||
function throttled(key, fn) {
|
||||
pending[key] = fn;
|
||||
if (!flushT) flushT = setTimeout(() => { flushT = null; const p = { ...pending }; for (const k in pending) delete pending[k]; Object.values(p).forEach(f => f()); }, 120);
|
||||
}
|
||||
|
||||
function banner(text, level = "error", secs = 6) {
|
||||
const b = $("banner");
|
||||
b.textContent = text; b.className = "banner show " + level;
|
||||
clearTimeout(bannerT);
|
||||
bannerT = setTimeout(() => b.className = "banner", secs * 1000);
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
S = s;
|
||||
const on = !!s.connected;
|
||||
$("led").className = "led " + (on ? "on" : (s.busy ? "busy" : ""));
|
||||
$("name").textContent = s.name || "Sin cámara";
|
||||
$("status").textContent = s.status || "";
|
||||
$("power").textContent = s.power || "";
|
||||
$("tc").textContent = s.tc || "00:00:00:00";
|
||||
$("tc").className = "tc" + (s.rec ? " rec" : "");
|
||||
$("dot").className = "dot" + (s.rec ? " rec" : "");
|
||||
$("c_fps").textContent = s.fps || "—";
|
||||
$("c_sh").textContent = s.shutter || "—";
|
||||
$("c_iris").textContent = s.iris || "—";
|
||||
$("c_iso").textContent = s.iso || "—";
|
||||
$("c_wb").textContent = s.wb ? s.wb + "K" : "—";
|
||||
$("c_tint").textContent = (s.tint === 0 || s.tint) ? (s.tint > 0 ? "+" + s.tint : String(s.tint)) : "—";
|
||||
$("peak").classList.toggle("on", !!s.peak);
|
||||
$("peaksw").style.background = PEAK_COL[s.peak_colour || 0];
|
||||
$("zebra").classList.toggle("on", !!s.zebra);
|
||||
$("false").classList.toggle("on", !!s.false);
|
||||
$("afauto").classList.toggle("on", !!s.af_auto);
|
||||
for (const id of ["af","peak","zebra","false","afauto","f_minus","f_plus","i_minus","i_plus","rec"]) $(id).disabled = !on;
|
||||
$("focus").disabled = !on; $("iris").disabled = !on;
|
||||
if (dragging !== "focus") { if (s.focus !== null && s.focus !== undefined) $("focus").value = Math.round(s.focus * 1000); }
|
||||
if (dragging !== "iris") { if (s.aperture !== null && s.aperture !== undefined) $("iris").value = Math.round(s.aperture * 1000); }
|
||||
$("focus_txt").textContent = s.focus_text || "—";
|
||||
$("iris_txt").textContent = s.iris || "—";
|
||||
$("rec").classList.toggle("rec", !!s.rec);
|
||||
$("recstate").textContent = s.rec ? "GRABANDO" : "GRABAR";
|
||||
$("recstate").className = "s" + (s.rec ? " rec" : "");
|
||||
$("recinfo").textContent = s.rec_info || (on ? "" : "—");
|
||||
$("lens").textContent = s.lens || "";
|
||||
if (s.notice && s.notice.t !== (render.lastNotice || 0)) { render.lastNotice = s.notice.t; banner(s.notice.text, s.notice.level, s.notice.secs || 6); }
|
||||
if (sheetKind) refreshSheet();
|
||||
}
|
||||
|
||||
// --- conexión con Labmagic (SSE con reintento)
|
||||
function listen() {
|
||||
const es = new EventSource("/events");
|
||||
es.onopen = () => { alive = true; };
|
||||
es.onmessage = e => { try { render(JSON.parse(e.data)); } catch (_) {} };
|
||||
es.onerror = () => {
|
||||
es.close(); alive = false;
|
||||
$("led").className = "led"; $("status").textContent = "Labmagic no está abierta: ábrela y este panel se conecta solo";
|
||||
setTimeout(listen, 2000);
|
||||
};
|
||||
}
|
||||
listen();
|
||||
|
||||
// --- botones
|
||||
$("name").onclick = () => cmd("connect");
|
||||
$("af").onclick = () => { cmd("af"); const b = $("af"); b.textContent = "AF ● enviado"; setTimeout(() => b.innerHTML = "AUTOFOCO (AF)", 900); };
|
||||
$("peak").onclick = () => cmd("peak", { on: !S.peak });
|
||||
$("peak").oncontextmenu = e => { e.preventDefault(); openSheet("peak"); };
|
||||
$("zebra").onclick = () => cmd("zebra", { on: !S.zebra });
|
||||
$("false").onclick = () => cmd("false", { on: !S.false });
|
||||
$("afauto").onclick = () => cmd("af_auto", { on: !S.af_auto });
|
||||
$("rec").onclick = () => cmd("record");
|
||||
$("open").onclick = () => cmd("show");
|
||||
const step = e => (e.shiftKey ? 0.05 : 0.01);
|
||||
$("f_minus").onclick = e => cmd("focus_delta", { d: -step(e) });
|
||||
$("f_plus").onclick = e => cmd("focus_delta", { d: step(e) });
|
||||
$("i_minus").onclick = e => cmd("iris_delta", { d: -0.05 });
|
||||
$("i_plus").onclick = e => cmd("iris_delta", { d: 0.05 });
|
||||
for (const [id, key, c] of [["focus", "focus", "focus"], ["iris", "iris", "iris"]]) {
|
||||
const el = $(id);
|
||||
el.oninput = () => { dragging = key; const v = el.value / 1000; if (key === "focus") $("focus_txt").textContent = v.toFixed(2); throttled(c, () => cmd(c, { v })); };
|
||||
el.onchange = () => { const v = el.value / 1000; cmd(c, { v }); setTimeout(() => dragging = null, 300); };
|
||||
}
|
||||
// Teclado: espacio = AF, flechas = foco (cuando el panel tiene el foco)
|
||||
document.addEventListener("keydown", e => {
|
||||
if (!S.connected) return;
|
||||
if (e.code === "Space") { e.preventDefault(); $("af").click(); }
|
||||
else if (e.code === "ArrowLeft") { e.preventDefault(); cmd("focus_delta", { d: -step(e) }); }
|
||||
else if (e.code === "ArrowRight") { e.preventDefault(); cmd("focus_delta", { d: step(e) }); }
|
||||
});
|
||||
|
||||
// --- selectores (hoja inferior)
|
||||
let sheetKind = null;
|
||||
const wbState = { k: 5600, t: 0 };
|
||||
document.querySelectorAll(".cell.click").forEach(c => c.onclick = () => openSheet(c.dataset.pick));
|
||||
$("veil").onclick = closeSheet;
|
||||
function closeSheet() { sheetKind = null; $("sheet").classList.remove("show"); $("veil").classList.remove("show"); }
|
||||
function openSheet(kind) {
|
||||
if (!S.connected) { banner("Conecta la cámara para cambiar valores", "warn", 3); return; }
|
||||
sheetKind = kind;
|
||||
if (kind === "wb") { wbState.k = S.wb || 5600; wbState.t = S.tint || 0; }
|
||||
buildSheet(kind);
|
||||
$("sheet").classList.add("show"); $("veil").classList.add("show");
|
||||
}
|
||||
const h = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
||||
function chips(parent, items, cur, onpick, dim) {
|
||||
const g = h("div", "chips");
|
||||
for (const [label, val] of items) {
|
||||
const c = h("div", "chip" + (dim && dim.has(val) ? " dim" : "") + (cur !== null && cur !== undefined && Math.abs(cur - val) < 1e-6 ? " sel" : ""), label);
|
||||
c.onclick = () => onpick(val);
|
||||
g.appendChild(c);
|
||||
}
|
||||
parent.appendChild(g);
|
||||
return g;
|
||||
}
|
||||
function head(parent, title, cur) {
|
||||
const hd = h("div", "sh-head");
|
||||
hd.appendChild(h("span", "t", title)); const cv = h("span", "cur", cur); hd.appendChild(cv);
|
||||
const x = h("span", "x", "✕"); x.onclick = closeSheet; hd.appendChild(x);
|
||||
parent.appendChild(hd);
|
||||
return cv;
|
||||
}
|
||||
function refreshSheet() { if (sheetKind && sheetKind !== "wb") buildSheet(sheetKind); }
|
||||
function buildSheet(kind) {
|
||||
const sh = $("sheet"); sh.innerHTML = "";
|
||||
if (kind === "iso") {
|
||||
const cur = parseInt(S.iso) || null;
|
||||
head(sh, "ISO", S.iso || "—");
|
||||
const pick = v => { cmd("iso", { v }); closeSheet(); };
|
||||
chips(sh, ISO_FULL.map(v => [String(v), v]), cur, pick);
|
||||
chips(sh, ISO_ALL.filter(v => !ISO_FULL.includes(v)).map(v => [String(v), v]), cur, pick, new Set(ISO_ALL));
|
||||
sh.appendChild(h("div", "note", "Nativos de la Pocket 4K: 400 y 3200 · tercios en gris"));
|
||||
} else if (kind === "shutter") {
|
||||
head(sh, "OBTURADOR", S.shutter || "—");
|
||||
const curA = S.angle, curS = S.speed;
|
||||
sh.appendChild(h("div", "sub", "ÁNGULO"));
|
||||
const pa = a => { cmd("angle", { a }); closeSheet(); };
|
||||
chips(sh, ANG_MAIN.map(a => [a + "°", a]), curA, pa);
|
||||
chips(sh, ANG_ALL.filter(a => !ANG_MAIN.includes(a)).map(a => [a + "°", a]), curA, pa, new Set(ANG_ALL));
|
||||
sh.appendChild(h("div", "sub", "VELOCIDAD 1/x"));
|
||||
chips(sh, SPEEDS.map(s => ["1/" + s, s]), curS, s => { cmd("speed", { s }); closeSheet(); });
|
||||
} else if (kind === "iris") {
|
||||
head(sh, "IRIS", S.iris || "—");
|
||||
chips(sh, FSTOPS.map(f => ["f/" + f, f]), null, f => { cmd("iris_f", { f }); closeSheet(); });
|
||||
const kv = h("div", "kv"); kv.appendChild(h("span", "", "abierto"));
|
||||
const r = h("input"); r.type = "range"; r.min = 0; r.max = 1000; r.value = Math.round((S.aperture || 0) * 1000);
|
||||
r.oninput = () => { dragging = "iris"; throttled("iris", () => cmd("iris", { v: r.value / 1000 })); };
|
||||
r.onchange = () => { cmd("iris", { v: r.value / 1000 }); setTimeout(() => dragging = null, 300); };
|
||||
kv.appendChild(r); kv.appendChild(h("span", "", "cerrado")); sh.appendChild(kv);
|
||||
const g = h("div", "chips"); const a = h("div", "chip", "AUTO IRIS"); a.onclick = () => { cmd("auto_iris"); closeSheet(); }; g.appendChild(a); sh.appendChild(g);
|
||||
} else if (kind === "wb") {
|
||||
const cv = head(sh, "BALANCE DE BLANCOS", "");
|
||||
const refresh = () => cv.textContent = `${wbState.k}K ${wbState.t >= 0 ? "+" : ""}${wbState.t}`;
|
||||
const send = () => throttled("wb", () => cmd("wb", { k: wbState.k, t: wbState.t }));
|
||||
refresh();
|
||||
const kv = h("div", "kv"); kv.appendChild(h("span", "", "2500"));
|
||||
const ks = h("input"); ks.type = "range"; ks.min = 2500; ks.max = 10000; ks.step = 50; ks.value = wbState.k;
|
||||
ks.oninput = () => { wbState.k = +ks.value; refresh(); send(); markPreset(); };
|
||||
kv.appendChild(ks); kv.appendChild(h("span", "", "10000")); sh.appendChild(kv);
|
||||
const ticks = h("div", "ticks"); for (const k of [3200, 4000, 5600, 6500, 7500]) ticks.appendChild(h("span", "", String(k))); sh.appendChild(ticks);
|
||||
const g = h("div", "chips"); g.style.gridTemplateColumns = "repeat(3, 1fr)";
|
||||
const presetChips = [];
|
||||
for (const [label, k, t] of WB_CHIPS) {
|
||||
const c = h("div", "chip", label); c.dataset.k = k;
|
||||
c.onclick = () => { wbState.k = k; wbState.t = t; ks.value = k; ts.value = t; refresh(); send(); markPreset(); };
|
||||
g.appendChild(c); presetChips.push(c);
|
||||
}
|
||||
const awb = h("div", "chip", "AUTO WB"); awb.onclick = () => { cmd("awb"); closeSheet(); }; g.appendChild(awb);
|
||||
sh.appendChild(g);
|
||||
const markPreset = () => presetChips.forEach(c => c.classList.toggle("sel", +c.dataset.k === wbState.k));
|
||||
const tr = h("div", "kv"); tr.style.gridTemplateColumns = "36px 1fr 30px 30px";
|
||||
tr.appendChild(h("span", "", "Tinte"));
|
||||
const ts = h("input"); ts.type = "range"; ts.min = -50; ts.max = 50; ts.value = wbState.t;
|
||||
ts.oninput = () => { wbState.t = +ts.value; refresh(); send(); };
|
||||
tr.appendChild(ts);
|
||||
for (const [txt, d] of [["−", -1], ["+", 1]]) { const b = h("button", "sq", txt); b.onclick = () => { wbState.t = Math.max(-50, Math.min(50, wbState.t + d)); ts.value = wbState.t; refresh(); send(); }; tr.appendChild(b); }
|
||||
sh.appendChild(tr);
|
||||
markPreset();
|
||||
} else if (kind === "peak") {
|
||||
head(sh, "COLOR DEL PEAKING", PEAK_NAME[S.peak_colour || 0]);
|
||||
const g = h("div", "chips");
|
||||
PEAK_NAME.forEach((n, i) => { const c = h("div", "chip" + (i === (S.peak_colour || 0) ? " sel" : "")); c.innerHTML = `<span class="swatch" style="background:${PEAK_COL[i]}"></span>${n}`; c.onclick = () => { cmd("peak_colour", { c: i }); closeSheet(); }; g.appendChild(c); });
|
||||
sh.appendChild(g);
|
||||
sh.appendChild(h("div", "note", "Clic derecho en PEAK abre este selector"));
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
icono.ico
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 35 KiB |
BIN
icono.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.4 KiB |
BIN
iconos/opcion-1.ico
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 33 KiB |
BIN
iconos/opcion-1.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.0 KiB |
BIN
iconos/opcion-2.ico
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 35 KiB |
BIN
iconos/opcion-2.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.4 KiB |
BIN
iconos/opcion-3.ico
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 32 KiB |
BIN
iconos/opcion-3.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.1 KiB |
BIN
iconos/opcion-4.ico
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 41 KiB |
BIN
iconos/opcion-4.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 4.1 KiB |
BIN
iconos/opciones.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 92 KiB |
140
make_icon.py
Archivo normal
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Genera el icono de Labmagic a partir del wordmark blanco real de Labstream.
|
||||
|
||||
- icono.ico / icono.png: la opción elegida (OPCION_POR_DEFECTO).
|
||||
- iconos/opcion-N.ico|png: todas las opciones, por si se quiere cambiar (basta copiar una sobre icono.ico).
|
||||
- iconos/opciones.png: hoja de muestra con las opciones a 256 px y a tamaño pequeño.
|
||||
"""
|
||||
import os
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
LOGO_BLANCO = r"C:\Users\Munka\OneDrive\Labstream\Propuestas\0_Modelo Propuestas\Logos\Logo Blanco.png"
|
||||
OPCION_POR_DEFECTO = 2
|
||||
|
||||
ROJO = (227, 38, 44, 255)
|
||||
ROJO_OSCURO = (170, 22, 28, 255)
|
||||
NEGRO = (16, 16, 16, 255)
|
||||
BLANCO = (255, 255, 255, 255)
|
||||
SS = 4 # supermuestreo
|
||||
|
||||
|
||||
def cargar_wordmark():
|
||||
"""Devuelve (wordmark 'labstream', 'lab' solo, logo completo con STUDIO), todos RGBA blancos recortados."""
|
||||
img = Image.open(LOGO_BLANCO).convert("RGBA")
|
||||
w, h = img.size
|
||||
full = img.crop(img.getbbox())
|
||||
word = img.crop((0, 0, w, int(h * 0.66)))
|
||||
word = word.crop(word.getbbox())
|
||||
lab = word.crop((0, 0, int(word.width * 0.295), word.height))
|
||||
lab = lab.crop(lab.getbbox())
|
||||
return word, lab, full
|
||||
|
||||
|
||||
def fondo(size, color, radio=0.22, borde=None, grosor=0.035, inset=0.065):
|
||||
s = size * SS
|
||||
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rounded_rectangle((0, 0, s - 1, s - 1), radius=int(s * radio), fill=color)
|
||||
if borde:
|
||||
i = int(s * inset)
|
||||
d.rounded_rectangle((i, i, s - 1 - i, s - 1 - i), radius=int(s * (radio - inset * 0.8)),
|
||||
outline=borde, width=max(1, int(s * grosor)))
|
||||
return img
|
||||
|
||||
|
||||
def pegar_centrado(base, art, ancho_rel, dy_rel=0.0):
|
||||
s = base.width
|
||||
ratio = art.height / art.width
|
||||
w = int(s * ancho_rel)
|
||||
h = int(w * ratio)
|
||||
art = art.resize((w, h), Image.LANCZOS)
|
||||
x = (s - w) // 2
|
||||
y = int((s - h) / 2 + s * dy_rel)
|
||||
base.alpha_composite(art, (x, y))
|
||||
return base
|
||||
|
||||
|
||||
def punto(base, cx_rel, cy_rel, r_rel, color):
|
||||
s = base.width
|
||||
d = ImageDraw.Draw(base)
|
||||
cx, cy, r = s * cx_rel, s * cy_rel, s * r_rel
|
||||
d.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color)
|
||||
return base
|
||||
|
||||
|
||||
def opcion(n, size):
|
||||
word, lab, full = cargar_wordmark()
|
||||
if n == 1: # Rojo, borde blanco fino, wordmark blanco
|
||||
base = fondo(size, ROJO, borde=BLANCO)
|
||||
return pegar_centrado(base, word, 0.74)
|
||||
if n == 2: # Rojo, wordmark grande "lab" + punto REC blanco
|
||||
base = fondo(size, ROJO, borde=BLANCO)
|
||||
pegar_centrado(base, lab, 0.56, dy_rel=-0.03)
|
||||
return punto(base, 0.5, 0.78, 0.055, BLANCO)
|
||||
if n == 3: # Negro con borde rojo, wordmark blanco y punto rojo
|
||||
base = fondo(size, NEGRO, borde=ROJO, grosor=0.05)
|
||||
pegar_centrado(base, word, 0.74, dy_rel=-0.04)
|
||||
return punto(base, 0.5, 0.74, 0.05, ROJO)
|
||||
if n == 4: # Rojo oscuro degradado, logo completo (labstream + STUDIO)
|
||||
s = size * SS
|
||||
grad = Image.new("RGBA", (s, s), ROJO)
|
||||
gd = ImageDraw.Draw(grad)
|
||||
for y in range(s):
|
||||
t = y / s
|
||||
c = tuple(int(ROJO[i] * (1 - t) + ROJO_OSCURO[i] * t) for i in range(3)) + (255,)
|
||||
gd.line((0, y, s, y), fill=c)
|
||||
mask = fondo(size, BLANCO)
|
||||
base = Image.new("RGBA", (s, s), (0, 0, 0, 0))
|
||||
base.paste(grad, (0, 0), mask)
|
||||
ImageDraw.Draw(base).rounded_rectangle((int(s * 0.065), int(s * 0.065), s - 1 - int(s * 0.065), s - 1 - int(s * 0.065)),
|
||||
radius=int(s * 0.17), outline=BLANCO, width=max(1, int(s * 0.03)))
|
||||
return pegar_centrado(base, full, 0.72)
|
||||
raise ValueError(n)
|
||||
|
||||
|
||||
def render(n, size):
|
||||
return opcion(n, size).resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def guardar_ico(n, ruta_ico, ruta_png):
|
||||
sizes = [16, 24, 32, 48, 64, 128, 256]
|
||||
imgs = [render(n, s) for s in sizes]
|
||||
imgs[-1].save(ruta_ico, format="ICO", sizes=[(s, s) for s in sizes], append_images=imgs[:-1])
|
||||
render(n, 64).save(ruta_png)
|
||||
|
||||
|
||||
def hoja(ruta):
|
||||
"""Hoja de muestra: cada opción a 256 px, y a 48/32/16 px para ver cómo se lee en pequeño."""
|
||||
try:
|
||||
font = ImageFont.truetype("C:/Windows/Fonts/segoeui.ttf", 22)
|
||||
fontb = ImageFont.truetype("C:/Windows/Fonts/segoeuib.ttf", 26)
|
||||
except Exception: # noqa: BLE001
|
||||
font = fontb = ImageFont.load_default()
|
||||
nombres = {1: "1 · Rojo, borde blanco, wordmark", 2: "2 · Rojo, 'lab' grande + punto",
|
||||
3: "3 · Negro, borde rojo, wordmark", 4: "4 · Rojo degradado, logo completo"}
|
||||
col_w, pad = 360, 40
|
||||
sheet = Image.new("RGBA", (col_w * 4 + pad, 470), (24, 24, 24, 255))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
d.text((pad, 14), "Opciones de icono para Labmagic", fill=(240, 240, 240, 255), font=fontb)
|
||||
for i, n in enumerate((1, 2, 3, 4)):
|
||||
x0 = pad + i * col_w
|
||||
sheet.alpha_composite(render(n, 256), (x0, 60))
|
||||
y = 340
|
||||
xx = x0
|
||||
for s in (48, 32, 16):
|
||||
sheet.alpha_composite(render(n, s), (xx, y + (48 - s) // 2))
|
||||
xx += s + 14
|
||||
d.text((x0, 405), nombres[n], fill=(200, 200, 200, 255), font=font)
|
||||
sheet.save(ruta)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out_dir = os.path.join(HERE, "iconos")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for n in (1, 2, 3, 4):
|
||||
guardar_ico(n, os.path.join(out_dir, f"opcion-{n}.ico"), os.path.join(out_dir, f"opcion-{n}.png"))
|
||||
guardar_ico(OPCION_POR_DEFECTO, os.path.join(HERE, "icono.ico"), os.path.join(HERE, "icono.png"))
|
||||
hoja(os.path.join(out_dir, "opciones.png"))
|
||||
print("icono por defecto: opción", OPCION_POR_DEFECTO, "->", os.path.join(HERE, "icono.ico"))
|
||||
247
obs_dock.py
Archivo normal
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Panel para OBS: servidor HTTP local que sirve `dock.html` y lo mantiene sincronizado con la app.
|
||||
|
||||
OBS Studio (28 o más) admite «Docks → Custom Browser Docks»: un panel acoplable que carga una URL.
|
||||
Labmagic levanta este servidor en 127.0.0.1 y el panel habla con la misma conexión Bluetooth que
|
||||
ya tiene la app (la cámara solo admite un proceso a la vez, así que el dock no se conecta solo).
|
||||
|
||||
GET / -> dock.html
|
||||
GET /state -> estado actual en JSON
|
||||
GET /events -> Server-Sent Events: un `data:` con el estado cada vez que cambia
|
||||
POST /cmd -> JSON {"cmd": "...", ...} que la app ejecuta en su hilo de Tk
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DEFAULT_PORT = 8790 # 8765 lo usa el Organizador Labstream
|
||||
|
||||
|
||||
class DockServer:
|
||||
def __init__(self, cmd_queue: queue.Queue, port: int = DEFAULT_PORT, html_path: str = None):
|
||||
self.cmd_queue = cmd_queue
|
||||
self.port = port
|
||||
self.html_path = html_path or os.path.join(HERE, "dock.html")
|
||||
self._state = {}
|
||||
self._state_json = "{}"
|
||||
self._clients = set()
|
||||
self._lock = threading.Lock()
|
||||
self._httpd = None
|
||||
self._thread = None
|
||||
self.error = None
|
||||
|
||||
# ----------------------------------------------------------- ciclo de vida
|
||||
def start(self):
|
||||
server = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_a): # silencio en consola
|
||||
pass
|
||||
|
||||
def _send(self, code, body: bytes, ctype="text/plain; charset=utf-8", extra=None):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
for k, v in (extra or {}).items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path in ("/", "/index.html", "/dock.html"):
|
||||
try:
|
||||
with open(server.html_path, "rb") as fh:
|
||||
self._send(200, fh.read(), "text/html; charset=utf-8")
|
||||
except OSError as e:
|
||||
self._send(500, f"No se encuentra dock.html: {e}".encode())
|
||||
elif path == "/state":
|
||||
self._send(200, server._state_json.encode("utf-8"), "application/json; charset=utf-8")
|
||||
elif path == "/events":
|
||||
self._sse()
|
||||
elif path == "/icon.png":
|
||||
icon = os.path.join(HERE, "icono.png")
|
||||
try:
|
||||
with open(icon, "rb") as fh:
|
||||
self._send(200, fh.read(), "image/png")
|
||||
except OSError:
|
||||
self._send(404, b"")
|
||||
else:
|
||||
self._send(404, b"no")
|
||||
|
||||
def _sse(self):
|
||||
q = queue.Queue(maxsize=200)
|
||||
with server._lock:
|
||||
server._clients.add(q)
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.end_headers()
|
||||
self.wfile.write(f"retry: 1500\ndata: {server._state_json}\n\n".encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
while True:
|
||||
try:
|
||||
msg = q.get(timeout=15)
|
||||
self.wfile.write(f"data: {msg}\n\n".encode("utf-8"))
|
||||
except queue.Empty:
|
||||
self.wfile.write(b": ping\n\n")
|
||||
self.wfile.flush()
|
||||
except (ConnectionError, OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
with server._lock:
|
||||
server._clients.discard(q)
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(n) if n else b""
|
||||
if path != "/cmd":
|
||||
self._send(404, b"no")
|
||||
return
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8") or "{}")
|
||||
if not isinstance(data, dict) or "cmd" not in data:
|
||||
raise ValueError("falta cmd")
|
||||
except (ValueError, UnicodeDecodeError) as e:
|
||||
self._send(400, f"JSON no válido: {e}".encode())
|
||||
return
|
||||
data["_t"] = time.time()
|
||||
server.cmd_queue.put(data)
|
||||
self._send(204, b"")
|
||||
|
||||
# Si el puerto está ocupado por otro programa probamos los siguientes (la URL se guarda en config)
|
||||
last_err = None
|
||||
for port in range(self.port, self.port + 10):
|
||||
try:
|
||||
self._httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
self._httpd.daemon_threads = True
|
||||
self.port = port
|
||||
break
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
self._httpd = None
|
||||
if self._httpd is None:
|
||||
self.error = f"No se pudo abrir ningún puerto entre {self.port} y {self.port + 9}: {last_err}"
|
||||
return False
|
||||
self._thread = threading.Thread(target=self._httpd.serve_forever, kwargs={"poll_interval": 0.5},
|
||||
name="labmagic-dock", daemon=True)
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
if self._httpd is not None:
|
||||
try:
|
||||
self._httpd.shutdown()
|
||||
self._httpd.server_close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._httpd = None
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return f"http://127.0.0.1:{self.port}/"
|
||||
|
||||
@property
|
||||
def clients(self):
|
||||
with self._lock:
|
||||
return len(self._clients)
|
||||
|
||||
# ----------------------------------------------------------- estado
|
||||
def publish(self, state: dict):
|
||||
"""Envía el estado a los paneles abiertos solo si ha cambiado."""
|
||||
js = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
|
||||
if js == self._state_json:
|
||||
return
|
||||
self._state, self._state_json = state, js
|
||||
with self._lock:
|
||||
clients = list(self._clients)
|
||||
for q in clients:
|
||||
try:
|
||||
q.put_nowait(js)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ registro en OBS
|
||||
def obs_user_ini():
|
||||
return os.path.join(os.environ.get("APPDATA", ""), "obs-studio", "user.ini")
|
||||
|
||||
|
||||
def obs_running():
|
||||
try:
|
||||
import subprocess
|
||||
out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq obs64.exe", "/NH"], capture_output=True, text=True,
|
||||
creationflags=0x08000000).stdout
|
||||
return "obs64.exe" in out
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def obs_add_browser_dock(title: str, url: str):
|
||||
"""Añade el panel a «Docks → Custom Browser Docks» de OBS editando user.ini (OBS debe estar cerrado).
|
||||
|
||||
Devuelve (ok, mensaje)."""
|
||||
ini = obs_user_ini()
|
||||
if not os.path.exists(ini):
|
||||
return False, "No se encuentra la configuración de OBS (user.ini). ¿Está instalado OBS 30 o superior?"
|
||||
if obs_running():
|
||||
return False, "Cierra OBS antes: guarda su configuración al salir y borraría el cambio."
|
||||
with open(ini, "r", encoding="utf-8-sig") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
docks = []
|
||||
idx = None
|
||||
section = None
|
||||
basic_end = None
|
||||
for i, line in enumerate(lines):
|
||||
s = line.strip()
|
||||
if s.startswith("[") and s.endswith("]"):
|
||||
if section == "BasicWindow" and basic_end is None:
|
||||
basic_end = i
|
||||
section = s[1:-1]
|
||||
elif section == "BasicWindow" and s.startswith("ExtraBrowserDocks="):
|
||||
idx = i
|
||||
try:
|
||||
docks = json.loads(s.split("=", 1)[1] or "[]")
|
||||
except ValueError:
|
||||
docks = []
|
||||
for d in docks:
|
||||
if isinstance(d, dict) and d.get("url", "").rstrip("/") == url.rstrip("/"):
|
||||
return True, "El panel ya estaba añadido en OBS (Docks → Labmagic)."
|
||||
docks.append({"title": title, "url": url, "uuid": str(uuid.uuid4())})
|
||||
new_line = "ExtraBrowserDocks=" + json.dumps(docks, ensure_ascii=False, separators=(",", ":"))
|
||||
if idx is not None:
|
||||
lines[idx] = new_line
|
||||
else:
|
||||
# Sin la clave: la añadimos al final de [BasicWindow] (o creamos la sección)
|
||||
pos = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "[BasicWindow]":
|
||||
pos = i + 1
|
||||
while pos < len(lines) and not lines[pos].strip().startswith("["):
|
||||
pos += 1
|
||||
break
|
||||
if pos is None:
|
||||
lines += ["", "[BasicWindow]", new_line]
|
||||
else:
|
||||
lines.insert(pos, new_line)
|
||||
backup = ini + ".labmagic.bak"
|
||||
try:
|
||||
if not os.path.exists(backup):
|
||||
with open(ini, "rb") as src, open(backup, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
with open(ini, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines) + "\n")
|
||||
except OSError as e:
|
||||
return False, f"No se pudo escribir user.ini: {e}"
|
||||
return True, "Panel añadido. Abre OBS: aparece en Docks → Labmagic (arrástralo donde quieras)."
|
||||
1
requirements.txt
Archivo normal
@@ -0,0 +1 @@
|
||||
bleak>=3.0
|
||||