commit 2ce1d650b59ff9610781b7f2cd322a9fa29b6522 Author: Labstream Date: Sat Oct 10 10:54:09 2026 -0500 Labmagic 1.0: control de la Blackmagic Pocket 4K por Bluetooth, panel para OBS y licencia MIT Co-Authored-By: Claude Fable 5.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14456c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Estado local de la app +config.json +labmagic.log + +# Compilación +__pycache__/ +*.pyc +build/ +dist/ +*.spec diff --git a/Compilar exe.bat b/Compilar exe.bat new file mode 100644 index 0000000..207b693 --- /dev/null +++ b/Compilar exe.bat @@ -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 +) diff --git a/Iniciar Labmagic.bat b/Iniciar Labmagic.bat new file mode 100644 index 0000000..ec6b0e6 --- /dev/null +++ b/Iniciar Labmagic.bat @@ -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 +) diff --git a/LICENCIA-ES.md b/LICENCIA-ES.md new file mode 100644 index 0000000..c3ca0b5 --- /dev/null +++ b/LICENCIA-ES.md @@ -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»). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7203e7f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3edae0f --- /dev/null +++ b/README.md @@ -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. diff --git a/TERCEROS.md b/TERCEROS.md new file mode 100644 index 0000000..f1e07b1 --- /dev/null +++ b/TERCEROS.md @@ -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.). diff --git a/app.py b/app.py new file mode 100644 index 0000000..a1c25a3 --- /dev/null +++ b/app.py @@ -0,0 +1,2457 @@ +""" +BMPCC Control: ventana para controlar una Blackmagic Pocket Cinema Camera por +Bluetooth LE desde Windows, con estética de la propia cámara (negro, HUD superior). + +Ejecutar: py -3.12 app.py (o "dist\\BMPCC Control.exe") +""" +import ctypes +import json +import os +import queue +import sys +import time +import tkinter as tk +import tkinter.font as tkfont +from tkinter import messagebox, simpledialog, ttk + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from bmpcc_ble import PARAM_NAMES, BleWorker, Camera, decode_data # noqa: E402 +from obs_dock import DEFAULT_PORT, DockServer, obs_add_browser_dock # noqa: E402 + +APP_TITLE = "Labmagic · control Blackmagic Pocket por Bluetooth" +APP_DIR = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)) +CONFIG_PATH = os.path.join(APP_DIR, "config.json") +LOG_PATH = os.path.join(APP_DIR, "labmagic.log") + +# ----------------------------------------------------------------- paleta +BG = "#0d0d0d" # fondo de la ventana +HUD = "#000000" # franja superior +BAR = "#141414" # barra de conexión +CARD = "#181818" # paneles +BORDER = "#2a2a2a" +FIELD = "#242424" # cajas de texto / combos +BTN = "#2b2b2b" +BTN_HOVER = "#3c3c3c" +BTN_PRESS = "#555555" +TROUGH = "#303030" +KNOB = "#ececec" +FG = "#f3f3f3" +MUTED = "#9b9b9b" +DIM = "#555555" +REC = "#e3262c" +LED_ON = "#3ad66c" +LED_BUSY = "#f0a500" +LED_OFF = "#4a4a4a" + +ISO_VALUES = [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] +SHUTTER_ANGLES = [11.2, 15, 22.5, 30, 37.5, 45, 60, 72, 90, 108, 120, 144, 150, 172.8, 180, 216, 270, 360] +SHUTTER_SPEEDS = [24, 25, 30, 48, 50, 60, 100, 120, 125, 200, 250, 500, 1000, 2000] +FSTOPS = [1.4, 1.8, 2.0, 2.8, 3.5, 4.0, 4.5, 5.6, 6.3, 8.0, 11, 16, 22] +WB_PRESETS = [ + ("Tungsteno 3200 K", 3200, 0), + ("Fluorescente 4000 K", 4000, 15), + ("Luz día 5600 K", 5600, 10), + ("Nublado 6500 K", 6500, 10), + ("Sombra 7500 K", 7500, 10), +] +WB_QUICK = [2500, 3200, 4000, 4500, 5000, 5600, 6500, 7500, 8000, 10000] +AE_MODES = ["Manual", "Iris", "Obturador", "Iris + Obturador", "Obturador + Iris"] +DR_MODES = ["Film", "Video", "Extended Video"] +LUTS = ["Ninguna", "Personalizada", "Film a Video", "Film a Extended Video"] +CODECS = {0: "CinemaDNG", 1: "DNxHD", 2: "ProRes", 3: "Blackmagic RAW"} +CODEC_VARIANTS = { + 0: {0: "sin comprimir", 1: "3:1", 2: "4:1"}, + 2: {0: "HQ", 1: "422", 2: "LT", 3: "Proxy", 4: "444", 5: "444 XQ"}, + 3: {0: "Q0", 7: "Q1", 8: "Q3", 1: "Q5", 2: "3:1", 3: "5:1", 4: "8:1", 5: "12:1"}, +} +# Opciones del panel de formato (etiqueta, códec básico, variante) +CODEC_CHOICES = [ + ("BRAW Q0", 3, 0), ("BRAW Q1", 3, 7), ("BRAW Q3", 3, 8), ("BRAW Q5", 3, 1), + ("BRAW 3:1", 3, 2), ("BRAW 5:1", 3, 3), ("BRAW 8:1", 3, 4), ("BRAW 12:1", 3, 5), + ("ProRes HQ", 2, 0), ("ProRes 422", 2, 1), ("ProRes LT", 2, 2), ("ProRes Proxy", 2, 3), +] +RESOLUTIONS = [ + ("4096×2160 DCI 4K", 4096, 2160), + ("4096×1720 2.4:1", 4096, 1720), + ("3840×2160 UHD", 3840, 2160), + ("2688×1512 2.6K", 2688, 1512), + ("1920×1080 HD", 1920, 1080), +] +FPS_CHOICES = ["23.98", "24", "25", "29.97", "30", "50", "59.94", "60"] +SENSOR_FPS_CHOICES = ["23.98", "24", "25", "29.97", "30", "48", "50", "59.94", "60", "100", "120"] +M_RATE = {"23.98": 24, "29.97": 30, "59.94": 60} +SHARPEN = ["Off", "Baja", "Media", "Alta"] +AUDIO_INPUTS = ["Micro interno", "Línea", "Micro nivel bajo", "Micro nivel alto"] +GUIDE_STYLES = ["Off", "2.4:1", "2.39:1", "2.35:1", "1.85:1", "16:9", "14:9", "4:3", "2:1", "4:5", "1:1"] +SHOT_TAGS = ["—", "WS", "CU", "MS", "BCU", "MCU", "ECU"] # índice - 1 = valor del protocolo (-1 = ninguna) +TAKE_TAGS = ["—", "PU", "VFX", "SER"] # índice - 1 = valor del protocolo +WB_PRESET_CHIPS = [("☀ Día", 5600, 10), ("💡 Tungst.", 3200, 0), ("▭ Fluor.", 4000, 15), ("☁ Nublado", 6500, 10), ("⌂ Sombra", 7500, 10)] + + +def fps_label(fps: int, m_rate: bool) -> str: + if m_rate: + for label, base in M_RATE.items(): + if base == fps: + return label + return f"{fps * 1000 / 1001:.2f}" + return str(fps) + + +def fps_parse(label: str): + """Devuelve (fps entero, M-rate) a partir de la etiqueta del combo.""" + if label in M_RATE: + return M_RATE[label], True + try: + return int(float(label)), False + except ValueError: + return 0, False + + +def load_config(): + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: # noqa: BLE001 + return {} + + +def save_config(cfg): + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + except Exception: # noqa: BLE001 + pass + + +def single_instance_or_focus() -> bool: + """Devuelve False si ya hay otra copia abierta (y la trae al frente).""" + try: + k32 = ctypes.windll.kernel32 + handle = k32.CreateMutexW(None, True, "Local\\Labmagic.SingleInstance") + if k32.GetLastError() == 183: # ERROR_ALREADY_EXISTS + u32 = ctypes.windll.user32 + hwnd = u32.FindWindowW(None, APP_TITLE) + if hwnd: + u32.ShowWindow(hwnd, 9) + u32.SetForegroundWindow(hwnd) + return False + globals()["_mutex_handle"] = handle + except Exception: # noqa: BLE001 + pass + return True + + +def make_icon(size=64) -> tk.PhotoImage: + """Icono dibujado a mano: aro gris y punto rojo sobre negro (como el REC de la cámara).""" + img = tk.PhotoImage(width=size, height=size) + c = size / 2 + rows = [] + for y in range(size): + row = [] + for x in range(size): + d = ((x + 0.5 - c) ** 2 + (y + 0.5 - c) ** 2) ** 0.5 + if d <= size * 0.30: + row.append(REC) + elif size * 0.40 <= d <= size * 0.47: + row.append("#d0d0d0") + else: + row.append("#101010") + rows.append("{" + " ".join(row) + "}") + img.put(" ".join(rows)) + return img + + +# ------------------------------------------------------------ widgets +class DarkSlider(tk.Canvas): + """Deslizador plano al estilo de la cámara: pista gris, tramo blanco y mando redondo.""" + + def __init__(self, parent, command=None, height=28, bg=CARD, **kw): + kw.setdefault("width", 120) # el Canvas de Tk pide 378 px por defecto y dispara el ancho de la ventana + super().__init__(parent, height=height, bg=bg, highlightthickness=0, **kw) + self._bg = bg + self.command = command + self.value = 0.0 + self.enabled = False + self._drag = False + self.bind("", lambda e: self._draw()) + self.bind("", self._press) + self.bind("", self._motion) + self.bind("", self._release) + + def _geom(self): + w = self.winfo_width() + pad = 12 + return pad, max(w - pad, pad + 1) + + def _draw(self): + self.delete("all") + x0, x1 = self._geom() + y = max(self.winfo_height() // 2, 1) + track = TROUGH if self.enabled else "#1f1f1f" + fill = KNOB if self.enabled else "#3a3a3a" + self.create_line(x0, y, x1, y, fill=track, width=4, capstyle="round") + xv = x0 + (x1 - x0) * self.value + if xv > x0: + self.create_line(x0, y, xv, y, fill=fill, width=4, capstyle="round") + r = 9 + self.create_oval(xv - r, y - r, xv + r, y + r, fill=fill, outline=self._bg, width=2) + + def set(self, v): + self.value = max(0.0, min(1.0, float(v))) + self._draw() + + def get(self): + return self.value + + def set_enabled(self, on): + self.enabled = bool(on) + self.configure(cursor="hand2" if on else "") + self._draw() + + def _press(self, e): + if not self.enabled: + return + self._drag = True + self._update(e.x) + + def _motion(self, e): + if self._drag: + self._update(e.x) + + def _release(self, _e): + self._drag = False + + def _update(self, x): + x0, x1 = self._geom() + self.set((x - x0) / (x1 - x0)) + if self.command: + self.command(self.value) + + +class HudCell(tk.Frame): + """Celda del HUD: etiqueta pequeña arriba y valor grande debajo (FPS, SHUTTER, IRIS...).""" + + def __init__(self, parent, label, font_value, on_click=None): + super().__init__(parent, bg=HUD, padx=18, pady=6) + self.on_click = on_click + self.lbl = tk.Label(self, text=label, bg=HUD, fg=MUTED, font=("Segoe UI", 7)) + self.val = tk.Label(self, text="—", bg=HUD, fg=FG, font=font_value) + self.lbl.pack() + self.val.pack() + # Señal de pulsable: línea fina bajo el valor en las celdas que abren un selector + self.under = tk.Frame(self, bg="#555555" if on_click else HUD, height=2) + self.under.pack(fill="x", padx=6, pady=(1, 0)) + self.configure(padx=6, pady=3) + for w in (self, self.lbl, self.val): + w.bind("", lambda e: self._hover(True)) + w.bind("", lambda e: self._hover(False)) + w.bind("", self._click) + if on_click: + self.configure(cursor="hand2") + + def _hover(self, on): + color = "#1a1a1a" if (on and self.on_click) else HUD + for w in (self, self.lbl, self.val): + w.configure(bg=color) + if self.on_click: + self.under.configure(bg=FG if on else "#555555") + + def _click(self, e): + if self.on_click: + self.on_click(e) + + def set(self, text, color=FG): + self.val.configure(text=text, fg=color) + + +# ---------------------------------------------------------------- app +class Tooltip: + """Globo de ayuda oscuro al pasar el ratón (sustituye a las notas largas en los paneles).""" + + def __init__(self, widget, text, delay=600): + self.widget, self.text, self.delay = widget, text, delay + self._id = None + self._win = None + widget.bind("", self._schedule, add="+") + widget.bind("", self._hide, add="+") + widget.bind("", self._hide, add="+") + + def _schedule(self, _e=None): + self._cancel() + self._id = self.widget.after(self.delay, self._show) + + def _cancel(self): + if self._id: + try: + self.widget.after_cancel(self._id) + except tk.TclError: + pass + self._id = None + + def _show(self): + if self._win or not self.text: + return + x = self.widget.winfo_rootx() + 10 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 6 + self._win = tk.Toplevel(self.widget) + self._win.overrideredirect(True) + self._win.attributes("-topmost", True) + lbl = tk.Label(self._win, text=self.text, bg="#2a2a2a", fg=FG, font=("Segoe UI", 9), justify="left", + wraplength=320, padx=8, pady=5, highlightthickness=1, highlightbackground="#555") + lbl.pack() + self._win.geometry(f"+{x}+{y}") + + def _hide(self, _e=None): + self._cancel() + if self._win: + try: + self._win.destroy() + except tk.TclError: + pass + self._win = None + + +class Picker(tk.Toplevel): + """Panel desplegable oscuro al estilo de la cámara: título, valor en grande y cuerpo a medida.""" + + def __init__(self, app, anchor, title, width=330): + super().__init__(app) + self.app = app + self.overrideredirect(True) + self.attributes("-topmost", True) + self.configure(bg="#161616", highlightthickness=1, highlightbackground="#4a4a4a") + head = tk.Frame(self, bg="#161616") + head.pack(fill="x", padx=12, pady=(10, 4)) + tk.Label(head, text=title.upper(), bg="#161616", fg=MUTED, font=("Segoe UI", 8)).pack(side="left") + self.value_lbl = tk.Label(head, text="", bg="#161616", fg=FG, font=app.F_TC_SMALL) + self.value_lbl.pack(side="right") + self.body = tk.Frame(self, bg="#161616") + self.body.pack(fill="both", expand=True, padx=12, pady=(0, 10)) + self._width = width + x = anchor.winfo_rootx() + anchor.winfo_width() // 2 - width // 2 + y = anchor.winfo_rooty() + anchor.winfo_height() + 4 + # Dentro del ancho de la ventana principal (y de la pantalla) + left, right = app.winfo_rootx(), app.winfo_rootx() + app.winfo_width() + x = max(left, min(x, right - width)) + x = max(8, min(x, self.winfo_screenwidth() - width - 8)) + self.geometry(f"+{x}+{y}") + self.bind("", lambda e: self.close()) + self.after(50, self._arm) + + def _arm(self): + try: + self.focus_force() + self.grab_set() + except tk.TclError: + pass + self.bind("", self._click, add="+") + + def _click(self, e): + # Con grab_set, los clics fuera del panel llegan aquí con coordenadas fuera de la ventana + if not (0 <= e.x_root - self.winfo_rootx() <= self.winfo_width() + and 0 <= e.y_root - self.winfo_rooty() <= self.winfo_height()): + self.close() + + def set_value(self, text): + self.value_lbl.configure(text=text) + + def chip(self, parent, text, on, cmd, dim=False, width=None): + bg = FG if on else ("#1e1e1e" if dim else "#2b2b2b") + fg = "#000000" if on else ("#8a8a8a" if dim else FG) + lbl = tk.Label(parent, text=text, bg=bg, fg=fg, font=self.app.F_UI, padx=8, pady=3, cursor="hand2", + width=width) + lbl.bind("", lambda e: cmd()) + if not on: + lbl.bind("", lambda e: lbl.configure(bg="#3c3c3c")) + lbl.bind("", lambda e: lbl.configure(bg="#1e1e1e" if dim else "#2b2b2b")) + return lbl + + def chips(self, items, current, cmd, per_row=6, dim_set=()): + """items: lista de (texto, valor). Dibuja filas de chips; el actual en blanco; dim_set en gris.""" + rowf = None + for i, (text, value) in enumerate(items): + if i % per_row == 0: + rowf = tk.Frame(self.body, bg="#161616") + rowf.pack(fill="x", pady=2) + c = self.chip(rowf, text, value == current, lambda v=value: cmd(v), dim=value in dim_set) + c.pack(side="left", padx=(0, 4), fill="x", expand=True) + + def close(self): + try: + self.grab_release() + except tk.TclError: + pass + try: + self.destroy() + except tk.TclError: + pass + if self.app._picker is self: + self.app._picker = None + + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title(APP_TITLE) + self.configure(bg=BG) + self.minsize(380, 300) + self.cfg = load_config() + + # Icono: PNG generado por make_icon.py (wordmark Labstream sobre rojo); si falta, uno dibujado + self._icon = None + for folder in (APP_DIR, getattr(sys, "_MEIPASS", None)): + if folder and os.path.exists(os.path.join(folder, "icono.png")): + try: + self._icon = tk.PhotoImage(file=os.path.join(folder, "icono.png")) + break + except Exception: # noqa: BLE001 + self._icon = None + if self._icon is None: + self._icon = make_icon() + try: + self.iconphoto(True, self._icon) + except Exception: # noqa: BLE001 + pass + + self.events = queue.Queue() + self.worker = BleWorker() + self.cam = Camera(self.events) + + self.devices = [] + self.connected = False + self.recording = False + self._pending = {} + self._flush_scheduled = False + self.focus_value = None + self.aperture_value = None + self.zoom_value = None + self.wb_kelvin = None + self.wb_tint = 0 + self._payload_count = 0 + self._connect_time = None + self._payload_warned = False + self._blink = False + self._lens_iris_text = None + self._link_held = False + self._autoconnected = False + self._lens_type = None + self._focus_reported = False + self._af_hint_shown = False + + self._notice = None + self.web_cmds = queue.Queue() + self.dock = DockServer(self.web_cmds, int(self.cfg.get("obs_port", DEFAULT_PORT))) + self.dock_running = False + if self.cfg.get("obs_dock", True): + self.dock_running = self.dock.start() + if self.dock_running and self.cfg.get("obs_port") != self.dock.port: + self.cfg["obs_port"] = self.dock.port + save_config(self.cfg) + + self._setup_fonts() + self._setup_styles() + self._build_ui() + if self.cfg.get("obs_dock", True) and not self.dock_running: + self.after(800, lambda: self.notify(self.dock.error or "No se pudo abrir el panel para OBS", "warn", 8)) + self.protocol("WM_DELETE_WINDOW", self.on_close) + self._poll_id = self.after(50, self._poll_events) + self.after(100, self._dark_title_bar) + self.after(300, self.scan) + self.after(600, self._blink_tick) + self.after(5000, self._af_tick) + self.bind_all("", self._space_af) + self.bind_all("", lambda e: self._arrow_focus(e, -0.01)) + self.bind_all("", lambda e: self._arrow_focus(e, 0.01)) + self.bind_all("", lambda e: self._arrow_focus(e, -0.05)) + self.bind_all("", lambda e: self._arrow_focus(e, 0.05)) + + # ------------------------------------------------------------ estilo + def _setup_fonts(self): + fams = set(tkfont.families()) + + def pick(*cands): + for c in cands: + if c in fams: + return c + return "Segoe UI" + + display = pick("Bahnschrift SemiBold", "Bahnschrift", "Segoe UI Semibold") + self.F_UI = ("Segoe UI", 10) + self.F_SMALL = ("Segoe UI", 8) + self.F_TITLE = (pick("Segoe UI Semibold", "Segoe UI"), 9) + self.F_VALUE = (display, 15) + self.F_TC = (display, 24) + self.F_TC_SMALL = (display, 20) + self.F_BIG = (pick("Segoe UI Semibold", "Segoe UI"), 13) + self.F_MONO = (pick("Cascadia Mono", "Consolas"), 9) + + def _setup_styles(self): + style = ttk.Style(self) + style.theme_use("clam") + style.configure(".", background=CARD, foreground=FG, fieldbackground=FIELD, bordercolor=BORDER, + lightcolor=CARD, darkcolor=CARD, troughcolor=TROUGH, font=self.F_UI, + focuscolor=CARD, selectbackground=FIELD, selectforeground=FG) + style.configure("TFrame", background=CARD) + style.configure("TLabel", background=CARD, foreground=FG) + style.configure("TButton", background=BTN, foreground=FG, bordercolor=BORDER, lightcolor=BTN, + darkcolor=BTN, padding=(12, 6), relief="flat", focuscolor=BTN) + style.map("TButton", + background=[("disabled", "#1e1e1e"), ("pressed", BTN_PRESS), ("active", BTN_HOVER)], + foreground=[("disabled", DIM)], + lightcolor=[("pressed", BTN_PRESS), ("active", BTN_HOVER)], + darkcolor=[("pressed", BTN_PRESS), ("active", BTN_HOVER)]) + style.configure("Big.TButton", font=self.F_BIG, padding=(12, 12)) + style.configure("Danger.TButton", foreground="#ff8a8a") + # Conmutadores: botón que se enciende en blanco cuando está activo (como la cámara) + style.configure("Toolbutton", background=BTN, foreground=FG, bordercolor=BORDER, lightcolor=BTN, + darkcolor=BTN, padding=(12, 6), relief="flat", focuscolor=BTN, anchor="center") + # Conmutador encendido: gris claro con borde rojo (distinto de las pestañas, que van subrayadas) + style.map("Toolbutton", + background=[("disabled", "#1e1e1e"), ("selected", "#3d3d3d"), ("active", BTN_HOVER)], + foreground=[("disabled", DIM), ("selected", FG)], + bordercolor=[("selected", REC)], + lightcolor=[("selected", REC)], darkcolor=[("selected", REC)]) + style.configure("Toolbutton", padding=(8, 4)) + style.configure("TButton", padding=(8, 4)) + style.configure("Big.TButton", padding=(8, 7)) + style.configure("TCombobox", fieldbackground=FIELD, background=BTN, foreground=FG, arrowcolor=FG, + bordercolor=BORDER, lightcolor=FIELD, darkcolor=FIELD, padding=(6, 4), arrowsize=14) + style.map("TCombobox", + fieldbackground=[("disabled", "#1c1c1c"), ("readonly", FIELD)], + foreground=[("disabled", DIM)], + selectbackground=[("readonly", FIELD)], selectforeground=[("readonly", FG)], + arrowcolor=[("disabled", DIM)]) + style.configure("TSpinbox", fieldbackground=FIELD, background=BTN, foreground=FG, arrowcolor=FG, + bordercolor=BORDER, lightcolor=FIELD, darkcolor=FIELD, insertcolor=FG, padding=(6, 4), + arrowsize=14) + style.map("TSpinbox", fieldbackground=[("disabled", "#1c1c1c")], foreground=[("disabled", DIM)], + arrowcolor=[("disabled", DIM)]) + style.configure("Vertical.TScrollbar", background=BTN, troughcolor=BG, bordercolor=BG, + arrowcolor=MUTED, lightcolor=BTN, darkcolor=BTN) + style.map("Vertical.TScrollbar", background=[("active", BTN_HOVER)]) + # Lista desplegable de los combos + self.option_add("*TCombobox*Listbox.background", FIELD) + self.option_add("*TCombobox*Listbox.foreground", FG) + self.option_add("*TCombobox*Listbox.selectBackground", FG) + self.option_add("*TCombobox*Listbox.selectForeground", "#000000") + self.option_add("*TCombobox*Listbox.font", self.F_UI) + self.option_add("*TCombobox*Listbox.borderWidth", 0) + + def _dark_title_bar(self): + """Barra de título oscura (Windows 10 20H1+ / 11) mediante DWM.""" + try: + self.update_idletasks() + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + value = ctypes.c_int(1) + for attr in (20, 19): + if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, attr, ctypes.byref(value), 4) == 0: + break + # Forzar el repintado del marco + g = self.geometry() + w, h, rest = g.split("x")[0], g.split("x")[1].split("+")[0], g.split("+", 1)[1] + self.geometry(f"{int(w) + 1}x{h}+{rest}") + self.after(30, lambda: self.geometry(g)) + except Exception: # noqa: BLE001 + pass + + # ---------------------------------------------------------------- UI + def _card(self, parent, title): + outer = tk.Frame(parent, bg=CARD, highlightbackground=BORDER, highlightthickness=1) + tk.Label(outer, text=title.upper(), bg=CARD, fg=MUTED, font=self.F_TITLE).pack( + anchor="w", padx=12, pady=(8, 2) + ) + inner = tk.Frame(outer, bg=CARD) + inner.pack(fill="both", expand=True, padx=12, pady=(0, 10)) + return outer, inner + + def _lbl(self, parent, text, muted=False, font=None, **kw): + kw.setdefault("bg", CARD) + return tk.Label(parent, text=text, fg=MUTED if muted else FG, font=font or self.F_UI, **kw) + + # ================================================================= interfaz + def _build_ui(self): + self._picker = None + self._pslider = {} + self._colour = {0: [0.0, 0.0, 0.0, 0.0], 1: [0.0, 0.0, 0.0, 0.0], 2: [1.0, 1.0, 1.0, 1.0], 3: [0.0, 0.0, 0.0, 0.0]} + self._contrast = [0.5, 1.0] + self._huesat = [0.0, 1.0] + self._guides = [0, 50, 90, 0] + self._audio_in = [0.5, 0.5] + self.expanded = bool(self.cfg.get("expanded", False)) + # Valores de exposición: ya no hay combos, los selectores del HUD y los presets usan estas variables + self.iso_var = tk.StringVar() + self.angle_var = tk.StringVar() + self.speed_var = tk.StringVar() + self.wb_var = tk.IntVar(value=5600) + self.tint_var = tk.IntVar(value=0) + self.wb_preset_var = tk.StringVar() + + # ------------------------------------------------ HUD mini (siempre visible) + hud = tk.Frame(self, bg=HUD) + hud.pack(fill="x") + top = tk.Frame(hud, bg=HUD) + top.pack(fill="x", padx=10, pady=(6, 0)) + top.columnconfigure(1, weight=1) + self.led = tk.Canvas(top, width=12, height=12, bg=HUD, highlightthickness=0) + self.led_item = self.led.create_oval(1, 1, 11, 11, fill=LED_OFF, outline="") + self.led.grid(row=0, column=0, padx=(0, 6)) + self.name_var = tk.StringVar(value="Sin cámara") + nl = tk.Label(top, textvariable=self.name_var, bg=HUD, fg=FG, font=self.F_TITLE, cursor="hand2") + nl.grid(row=0, column=1, sticky="w") + nl.bind("", lambda e: self.toggle_connect()) + Tooltip(nl, "Pulsa para conectar o desconectar") + self.tc_var = tk.StringVar(value="00:00:00:00") + self.tc_label = tk.Label(top, textvariable=self.tc_var, bg=HUD, fg=FG, font=self.F_TC) + self.tc_label.grid(row=0, column=2, rowspan=2, padx=8) + self.rec_dot = tk.Label(top, text="●", bg=HUD, fg=HUD, font=("Segoe UI", 11)) + self.rec_dot.grid(row=0, column=3, rowspan=2) + tools = tk.Frame(top, bg=HUD) + tools.grid(row=0, column=4, rowspan=2, sticky="e") + self.topmost_var = tk.BooleanVar(value=bool(self.cfg.get("topmost", True))) + b = ttk.Checkbutton(tools, text="📌", variable=self.topmost_var, style="Toolbutton", width=3, + command=self._apply_topmost) + b.pack(side="left") + Tooltip(b, "Siempre encima de OBS y del resto de ventanas") + self.expand_btn = ttk.Button(tools, text="⇱", width=3, command=self.toggle_expand) + self.expand_btn.pack(side="left", padx=(4, 0)) + Tooltip(self.expand_btn, "Ampliar o plegar: muestra las pestañas con el resto de ajustes") + self.status_var = tk.StringVar(value="Desconectada") + tk.Label(top, textvariable=self.status_var, bg=HUD, fg=MUTED, font=self.F_SMALL).grid( + row=1, column=0, columnspan=2, sticky="w") + self.lens_var = tk.StringVar(value="") + self.power_var = tk.StringVar(value="—") + info = tk.Frame(hud, bg=HUD) + info.pack(fill="x", padx=10) + tk.Label(info, textvariable=self.lens_var, bg=HUD, fg=MUTED, font=self.F_SMALL).pack(side="left") + tk.Label(info, textvariable=self.power_var, bg=HUD, fg=MUTED, font=self.F_SMALL).pack(side="right") + + cells = tk.Frame(hud, bg=HUD) + cells.pack(fill="x", padx=6, pady=(4, 6)) + self.cell_fps = HudCell(cells, "FPS", self.F_VALUE) + self.cell_shutter = HudCell(cells, "SHUTTER", self.F_VALUE, lambda e: self._pick_shutter(self.cell_shutter)) + self.cell_iris = HudCell(cells, "IRIS", self.F_VALUE, lambda e: self._pick_iris(self.cell_iris)) + self.cell_iso = HudCell(cells, "ISO", self.F_VALUE, lambda e: self._pick_iso(self.cell_iso)) + self.cell_wb = HudCell(cells, "WB", self.F_VALUE, lambda e: self._pick_wb(self.cell_wb)) + self.cell_tint = HudCell(cells, "TINT", self.F_VALUE, lambda e: self._pick_wb(self.cell_tint)) + for i, c in enumerate((self.cell_fps, self.cell_shutter, self.cell_iris, self.cell_iso, + self.cell_wb, self.cell_tint)): + if i: + tk.Frame(cells, bg="#2a2a2a", width=1).pack(side="left", fill="y", pady=4) + c.pack(side="left", fill="x", expand=True) + tk.Frame(hud, bg=BORDER, height=1).pack(fill="x") + + # Franja de avisos (oculta hasta que haga falta) + self.banner = tk.Label(self, text="", bg=REC, fg="#fff", font=self.F_UI, anchor="w", padx=10, pady=4) + self._banner_id = None + + # ------------------------------------------------ bloque mini: lo que se toca en una toma + mini = tk.Frame(self, bg=BG) + mini.pack(fill="x", padx=8, pady=(6, 4)) + mini.columnconfigure(1, weight=1) + row0 = tk.Frame(mini, bg=BG) + row0.grid(row=0, column=0, columnspan=3, sticky="ew") + row0.columnconfigure(0, weight=1) + self.btn_af = ttk.Button(row0, text="AUTOFOCO (AF)", style="Big.TButton", command=self.autofocus) + self.btn_af.grid(row=0, column=0, sticky="ew") + Tooltip(self.btn_af, "Autofoco de un disparo (también con la barra espaciadora)") + self.peak_var = tk.BooleanVar() + self.peak_colour = int(self.cfg.get("peak_colour", 0)) + self.peak_toggle = ttk.Checkbutton(row0, text="PEAK", variable=self.peak_var, style="Toolbutton", + command=self.apply_peaking) + self.peak_toggle.grid(row=0, column=1, sticky="ns", padx=(6, 0)) + Tooltip(self.peak_toggle, "Focus assist con líneas de color en la salida elegida (Monitor)") + self.peak_colour_btn = ttk.Button(row0, text=self._peak_colour_text(), width=7, command=self._menu_peak_colour) + self.peak_colour_btn.grid(row=0, column=2, sticky="ns", padx=(4, 0)) + self.af_auto_var = tk.BooleanVar(value=False) + afa = ttk.Checkbutton(row0, text="AF auto", variable=self.af_auto_var, style="Toolbutton") + afa.grid(row=0, column=3, sticky="ns", padx=(6, 0)) + self.af_every_var = tk.IntVar(value=int(self.cfg.get("af_every", 5))) + Tooltip(afa, "Repite el autofoco cada N segundos (ajusta N en Ajustes)") + self.rec_canvas = tk.Canvas(row0, width=48, height=48, bg=BG, highlightthickness=0, cursor="hand2") + self.rec_canvas.grid(row=0, column=4, padx=(10, 0)) + self.rec_canvas.bind("", lambda e: self.toggle_record()) + self.rec_canvas.bind("", lambda e: self._draw_rec(hover=True)) + self.rec_canvas.bind("", lambda e: self._draw_rec(hover=False)) + Tooltip(self.rec_canvas, "Grabar / parar") + + self._lbl(mini, "Foco", bg=BG).grid(row=1, column=0, sticky="w", pady=(6, 0)) + self.focus_slider = DarkSlider(mini, bg=BG, command=lambda v: self._on_slider("focus", v)) + self.focus_slider.grid(row=1, column=1, sticky="ew", padx=6, pady=(6, 0)) + fr = tk.Frame(mini, bg=BG) + fr.grid(row=1, column=2, pady=(6, 0)) + self.focus_lbl = self._lbl(fr, "—", width=10, anchor="e", bg=BG) + self.focus_lbl.pack(side="left") + self.focus_nudge_btns = [] + for txt, d in (("◀", -0.02), ("▶", 0.02)): + b = ttk.Button(fr, text=txt, width=2, command=lambda d=d: self.nudge_focus(d)) + b.pack(side="left", padx=(3, 0)) + self.focus_nudge_btns.append(b) + + self._lbl(mini, "Iris", bg=BG).grid(row=2, column=0, sticky="w", pady=(4, 0)) + self.aperture_slider = DarkSlider(mini, bg=BG, command=lambda v: self._on_slider("aperture", v)) + self.aperture_slider.grid(row=2, column=1, sticky="ew", padx=6, pady=(4, 0)) + ir = tk.Frame(mini, bg=BG) + ir.grid(row=2, column=2, pady=(4, 0)) + self.aperture_lbl = self._lbl(ir, "—", width=10, anchor="e", bg=BG) + self.aperture_lbl.pack(side="left") + self.aperture_nudge_btns = [] + for txt, d in (("◀", -0.05), ("▶", 0.05)): + b = ttk.Button(ir, text=txt, width=2, command=lambda d=d: self.nudge_aperture(d)) + b.pack(side="left", padx=(3, 0)) + self.aperture_nudge_btns.append(b) + + statusrow = tk.Frame(mini, bg=BG) + statusrow.grid(row=3, column=0, columnspan=3, sticky="ew", pady=(6, 0)) + self.rec_state_var = tk.StringVar(value="GRABAR") + self.rec_state_lbl = tk.Label(statusrow, textvariable=self.rec_state_var, bg=BG, fg=FG, font=self.F_TITLE) + self.rec_state_lbl.pack(side="left") + self.transport_var = tk.StringVar(value="—") + self.format_var = tk.StringVar(value="") + self.codec_var = tk.StringVar(value="") + self.rec_info_var = tk.StringVar(value="") + tk.Label(statusrow, textvariable=self.rec_info_var, bg=BG, fg=MUTED, font=self.F_SMALL).pack(side="left", padx=(8, 0)) + self.focus_note_var = tk.StringVar(value="espacio = AF · ← → foco · Mayús = pasos grandes") + tk.Label(mini, textvariable=self.focus_note_var, bg=BG, fg=DIM, font=self.F_SMALL, anchor="w").grid( + row=4, column=0, columnspan=3, sticky="w", pady=(2, 0)) + self._draw_rec() + + # ------------------------------------------------ bloque ampliado: pestañas subrayadas y páginas + self.expand_frame = tk.Frame(self, bg=BG) + tabs = tk.Frame(self.expand_frame, bg=BAR) + tabs.pack(fill="x") + self.tab_var = tk.StringVar(value=self.cfg.get("tab", "rec")) + self._tab_widgets = {} + for text, key in (("GRABAR", "rec"), ("MONITOR", "mon"), ("AUDIO", "aud"), ("COLOR", "col"), ("AJUSTES", "set")): + cell = tk.Frame(tabs, bg=BAR, cursor="hand2") + cell.pack(side="left", padx=(10 if key == "rec" else 6, 0)) + lab = tk.Label(cell, text=text, bg=BAR, fg=MUTED, font=self.F_TITLE, pady=5) + lab.pack() + line = tk.Frame(cell, bg=BAR, height=2) + line.pack(fill="x") + for w in (cell, lab): + w.bind("", lambda e, k=key: self._select_tab(k)) + self._tab_widgets[key] = (lab, line) + container = tk.Frame(self.expand_frame, bg=BG) + container.pack(fill="both", expand=True) + container.rowconfigure(0, weight=1) + container.columnconfigure(0, weight=1) + self.pages = {} + for key in ("set", "col", "aud", "mon", "rec"): + page = tk.Frame(container, bg=BG) + page.grid(row=0, column=0, sticky="nsew") + page.columnconfigure(0, weight=1) + self.pages[key] = page + + self._build_page_rec(self.pages["rec"]) + self._build_page_mon(self.pages["mon"]) + self._build_page_aud(self.pages["aud"]) + self._build_page_col(self.pages["col"]) + self._build_page_set(self.pages["set"]) + + self._select_tab(self.tab_var.get() if self.tab_var.get() in self.pages else "rec") + self._apply_expand(initial=True) + self._apply_topmost() + self._set_controls_enabled(False) + + # ---------------------------------------------------------------- páginas + def _build_page_rec(self, p): + card, f = self._card(p, "Formato de grabación") + card.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 3)) + f.columnconfigure(1, weight=1) + f.columnconfigure(3, weight=1) + self._lbl(f, "Códec").grid(row=0, column=0, sticky="w", pady=2) + self.codec_sel_var = tk.StringVar() + self.codec_combo = ttk.Combobox(f, textvariable=self.codec_sel_var, values=[c[0] for c in CODEC_CHOICES], + state="readonly", width=12) + self.codec_combo.grid(row=0, column=1, sticky="w", padx=4) + self._lbl(f, "Resolución").grid(row=0, column=2, sticky="w", padx=(8, 0)) + self.res_var = tk.StringVar() + self.res_combo = ttk.Combobox(f, textvariable=self.res_var, values=[r[0] for r in RESOLUTIONS], + state="readonly", width=16) + self.res_combo.grid(row=0, column=3, sticky="w", padx=4) + self._lbl(f, "Fotogramas").grid(row=1, column=0, sticky="w", pady=2) + self.fps_var = tk.StringVar() + self.fps_combo = ttk.Combobox(f, textvariable=self.fps_var, values=FPS_CHOICES, state="readonly", width=7) + self.fps_combo.grid(row=1, column=1, sticky="w", padx=4) + self._lbl(f, "Sensor").grid(row=1, column=2, sticky="w", padx=(8, 0)) + self.sfps_var = tk.StringVar(value="igual") + self.sfps_combo = ttk.Combobox(f, textvariable=self.sfps_var, values=["igual"] + SENSOR_FPS_CHOICES, + state="readonly", width=7) + self.sfps_combo.grid(row=1, column=3, sticky="w", padx=4) + Tooltip(self.sfps_combo, "Fotogramas del sensor para off-speed (cámara lenta); «igual» = sin off-speed") + self.window_var = tk.BooleanVar() + self.window_toggle = ttk.Checkbutton(f, text="Ventana del sensor", variable=self.window_var, style="Toolbutton") + self.window_toggle.grid(row=2, column=0, columnspan=2, sticky="w", pady=(6, 0)) + self.btn_format = ttk.Button(f, text="Aplicar formato", command=self.apply_format) + self.btn_format.grid(row=2, column=2, columnspan=2, sticky="e", pady=(6, 0)) + + card, f = self._card(p, "Imagen") + card.grid(row=1, column=0, sticky="ew", padx=8, pady=3) + f.columnconfigure(1, weight=1) + f.columnconfigure(3, weight=1) + self._lbl(f, "Rango din.").grid(row=0, column=0, sticky="w", pady=2) + self.dr_var = tk.StringVar() + self.dr_combo = ttk.Combobox(f, textvariable=self.dr_var, values=DR_MODES, state="readonly", width=13) + self.dr_combo.grid(row=0, column=1, sticky="w", padx=4) + self.dr_combo.bind("<>", + lambda e: self._send(self.cam.set_dynamic_range(DR_MODES.index(self.dr_var.get())), "Rango dinámico")) + self._lbl(f, "Autoexp.").grid(row=0, column=2, sticky="w", padx=(8, 0)) + self.ae_var = tk.StringVar() + self.ae_combo = ttk.Combobox(f, textvariable=self.ae_var, values=AE_MODES, state="readonly", width=14) + self.ae_combo.grid(row=0, column=3, sticky="w", padx=4) + self.ae_combo.bind("<>", + lambda e: self._send(self.cam.set_auto_exposure_mode(AE_MODES.index(self.ae_var.get())), "Autoexposición")) + self._lbl(f, "Nitidez").grid(row=1, column=0, sticky="w", pady=2) + self.sharp_var = tk.StringVar() + cb = ttk.Combobox(f, textvariable=self.sharp_var, values=SHARPEN, state="readonly", width=13) + cb.grid(row=1, column=1, sticky="w", padx=4) + cb.bind("<>", lambda e: self._send(self.cam.set_sharpening(SHARPEN.index(self.sharp_var.get())), "Nitidez")) + self.sharp_combo = cb + self._lbl(f, "Ganancia dB").grid(row=1, column=2, sticky="w", padx=(8, 0)) + self.gain_var = tk.IntVar(value=0) + sp = ttk.Spinbox(f, from_=-12, to=36, increment=2, textvariable=self.gain_var, width=5, + command=lambda: self._send(self.cam.set_gain_db(self.gain_var.get()), "Ganancia")) + sp.grid(row=1, column=3, sticky="w", padx=4) + sp.bind("", lambda e: self._send(self.cam.set_gain_db(self.gain_var.get()), "Ganancia")) + self.gain_spin = sp + ttk.Button(f, text="Último auto WB", command=lambda: self._send(self.cam.send(1, 4, 0, 0), "Restaurar auto WB")).grid( + row=2, column=0, columnspan=2, sticky="w", pady=(6, 0)) + self.ois_var = tk.BooleanVar() + self.ois_toggle = ttk.Checkbutton(f, text="Estabilización (OIS)", variable=self.ois_var, style="Toolbutton", + command=lambda: self._send(self.cam.set_ois(self.ois_var.get()), "OIS")) + self.ois_toggle.grid(row=2, column=2, columnspan=2, sticky="e", pady=(6, 0)) + + card, f = self._card(p, "Reproducción") + card.grid(row=2, column=0, sticky="ew", padx=8, pady=3) + row = tk.Frame(f, bg=CARD) + row.pack(fill="x") + for text, cmd, tip in (("◼ Preview", lambda: self._send(self.cam.set_transport(0), "Preview"), "Volver a la vista en directo"), + ("▶ Play", lambda: self._send(self.cam.set_transport(1, 1, self.loop_var.get(), self.playall_var.get()), "Play"), "Reproducir el clip actual"), + ("❚❚ Pausa", lambda: self._send(self.cam.set_transport(1, 0, self.loop_var.get(), self.playall_var.get()), "Pausa"), "Pausar la reproducción"), + ("⏮", lambda: self._send(self.cam.playback_clip(False), "Clip anterior"), "Clip anterior"), + ("⏭", lambda: self._send(self.cam.playback_clip(True), "Clip siguiente"), "Clip siguiente")): + b = ttk.Button(row, text=text, command=cmd) + b.pack(side="left", padx=(0, 4)) + Tooltip(b, tip) + self.loop_var = tk.BooleanVar() + self.playall_var = tk.BooleanVar() + ttk.Checkbutton(row, text="Bucle", variable=self.loop_var, style="Toolbutton").pack(side="left", padx=(6, 4)) + ttk.Checkbutton(row, text="Todos", variable=self.playall_var, style="Toolbutton").pack(side="left") + + card, f = self._card(p, "Pizarra") + card.grid(row=3, column=0, sticky="ew", padx=8, pady=(3, 6)) + for c in (1, 3, 5): + f.columnconfigure(c, weight=1) + self.reel_var = tk.IntVar(value=1) + self.scene_var = tk.StringVar() + self.take_var = tk.IntVar(value=1) + self.shot_var = tk.StringVar(value=SHOT_TAGS[0]) + self.intext_var = tk.StringVar(value="Interior") + self.daynight_var = tk.StringVar(value="Día") + self.taketag_var = tk.StringVar(value=TAKE_TAGS[0]) + self.goodtake_var = tk.BooleanVar() + self.camid_var = tk.StringVar() + self.operator_var = tk.StringVar() + self.director_var = tk.StringVar() + self.project_var = tk.StringVar() + + def spin(parent, var, lo, hi, cmd, w=4): + s = ttk.Spinbox(parent, from_=lo, to=hi, textvariable=var, width=w, command=cmd) + s.bind("", lambda e: cmd()) + return s + + def entry(parent, var, cmd, w=12): + e = tk.Entry(parent, textvariable=var, width=w, bg=FIELD, fg=FG, insertbackground=FG, relief="flat", + highlightthickness=1, highlightbackground=BORDER, highlightcolor=FG, font=self.F_UI) + e.bind("", lambda ev: cmd()) + e.bind("", lambda ev: cmd()) + return e + + self._lbl(f, "Reel").grid(row=0, column=0, sticky="w", pady=2) + spin(f, self.reel_var, 1, 999, lambda: self._send(self.cam.set_reel(self.reel_var.get()), "Reel")).grid(row=0, column=1, sticky="w", padx=4) + self._lbl(f, "Escena").grid(row=0, column=2, sticky="w", padx=(8, 0)) + entry(f, self.scene_var, lambda: self._send(self.cam.set_scene(self.scene_var.get()), "Escena"), 6).grid(row=0, column=3, sticky="w", padx=4) + self._lbl(f, "Toma").grid(row=0, column=4, sticky="w", padx=(8, 0)) + spin(f, self.take_var, 1, 99, self._send_take).grid(row=0, column=5, sticky="w", padx=4) + self._lbl(f, "Plano").grid(row=1, column=0, sticky="w", pady=2) + cb = ttk.Combobox(f, textvariable=self.shot_var, values=SHOT_TAGS, state="readonly", width=6) + cb.grid(row=1, column=1, sticky="w", padx=4) + cb.bind("<>", lambda e: self._send_scene_tags()) + cb = ttk.Combobox(f, textvariable=self.intext_var, values=["Interior", "Exterior"], state="readonly", width=8) + cb.grid(row=1, column=2, columnspan=2, sticky="w", padx=4) + cb.bind("<>", lambda e: self._send_scene_tags()) + cb = ttk.Combobox(f, textvariable=self.daynight_var, values=["Día", "Noche"], state="readonly", width=6) + cb.grid(row=1, column=4, columnspan=2, sticky="w", padx=4) + cb.bind("<>", lambda e: self._send_scene_tags()) + self._lbl(f, "Etiq. toma").grid(row=2, column=0, sticky="w", pady=2) + cb = ttk.Combobox(f, textvariable=self.taketag_var, values=TAKE_TAGS, state="readonly", width=6) + cb.grid(row=2, column=1, sticky="w", padx=4) + cb.bind("<>", lambda e: self._send_take()) + ttk.Checkbutton(f, text="Buena toma", variable=self.goodtake_var, style="Toolbutton", + command=lambda: self._send(self.cam.set_good_take(self.goodtake_var.get()), "Buena toma")).grid( + row=2, column=2, columnspan=2, sticky="w", padx=4) + self._lbl(f, "Id cámara").grid(row=2, column=4, sticky="w", padx=(8, 0)) + entry(f, self.camid_var, lambda: self._send(self.cam.set_metadata_text(5, self.camid_var.get()), "Id cámara"), 4).grid(row=2, column=5, sticky="w", padx=4) + self._lbl(f, "Operador").grid(row=3, column=0, sticky="w", pady=2) + entry(f, self.operator_var, lambda: self._send(self.cam.set_metadata_text(6, self.operator_var.get()), "Operador"), 14).grid(row=3, column=1, columnspan=2, sticky="ew", padx=4) + self._lbl(f, "Director").grid(row=3, column=3, sticky="e") + entry(f, self.director_var, lambda: self._send(self.cam.set_metadata_text(7, self.director_var.get()), "Director"), 14).grid(row=3, column=4, columnspan=2, sticky="ew", padx=4) + self._lbl(f, "Proyecto").grid(row=4, column=0, sticky="w", pady=2) + entry(f, self.project_var, lambda: self._send(self.cam.set_metadata_text(8, self.project_var.get()), "Proyecto"), 14).grid(row=4, column=1, columnspan=5, sticky="ew", padx=4) + + def _build_page_mon(self, p): + card, f = self._card(p, "Ayudas y overlays") + card.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 3)) + self.target_var = tk.StringVar(value=self.cfg.get("monitor_target", "hdmi")) + trow = tk.Frame(f, bg=CARD) + trow.pack(fill="x") + self._lbl(trow, "Aplicar en", muted=True).pack(side="left", padx=(0, 6)) + for text, val in (("HDMI", "hdmi"), ("LCD", "lcd"), ("Ambas", "both")): + ttk.Radiobutton(trow, text=text, value=val, variable=self.target_var, style="Toolbutton", + command=self._on_target_change).pack(side="left", padx=(0, 4)) + self.zebra_var = tk.BooleanVar() + self.false_var = tk.BooleanVar() + toggles = tk.Frame(f, bg=CARD) + toggles.pack(fill="x", pady=(6, 0)) + for text, var, cmd in (("Zebra", self.zebra_var, self.apply_tools), ("Peaking", self.peak_var, self.apply_peaking), + ("Falso color", self.false_var, self.apply_tools)): + ttk.Checkbutton(toggles, text=text, variable=var, style="Toolbutton", command=cmd).pack(side="left", padx=(0, 4), fill="x", expand=True) + self.status_ov_var = tk.BooleanVar(value=True) + self.guides_ov_var = tk.BooleanVar() + self.clean_ov_var = tk.BooleanVar() + ovrow = tk.Frame(f, bg=CARD) + ovrow.pack(fill="x", pady=(4, 0)) + for text, var in (("Texto estado", self.status_ov_var), ("Guías", self.guides_ov_var), ("Señal limpia", self.clean_ov_var)): + ttk.Checkbutton(ovrow, text=text, variable=var, style="Toolbutton", command=self.apply_overlays).pack(side="left", padx=(0, 4), fill="x", expand=True) + lrow = tk.Frame(f, bg=CARD) + lrow.pack(fill="x", pady=(6, 0)) + self._lbl(lrow, "LUT").pack(side="left") + self.lut_var = tk.StringVar() + self.lut_combo = ttk.Combobox(lrow, textvariable=self.lut_var, values=LUTS, state="readonly", width=18) + self.lut_combo.pack(side="left", padx=4) + self.lut_combo.bind("<>", lambda e: self.apply_lut()) + self.lut_on_var = tk.BooleanVar(value=False) + self.lut_toggle = ttk.Checkbutton(lrow, text="Mostrar LUT", variable=self.lut_on_var, style="Toolbutton", command=self.apply_lut) + self.lut_toggle.pack(side="left") + ttk.Button(lrow, text="Barras 5 s", command=lambda: self._send(self.cam.show_colour_bars(5), "Barras")).pack(side="right") + + card, f = self._card(p, "Guías y rejilla") + card.grid(row=1, column=0, sticky="ew", padx=8, pady=3) + f.columnconfigure(1, weight=1) + grow = tk.Frame(f, bg=CARD) + grow.grid(row=0, column=0, columnspan=3, sticky="ew") + self._lbl(grow, "Estilo").pack(side="left") + self.guide_style_var = tk.StringVar(value=GUIDE_STYLES[0]) + cb = ttk.Combobox(grow, textvariable=self.guide_style_var, values=GUIDE_STYLES, state="readonly", width=8) + cb.pack(side="left", padx=4) + cb.bind("<>", lambda e: self._send_guides()) + self.grid_vars = {} + for text, bit in (("Tercios", 1), ("Cruz", 2), ("Punto", 4), ("Horizonte", 8)): + v = tk.BooleanVar() + self.grid_vars[bit] = v + ttk.Checkbutton(grow, text=text, variable=v, style="Toolbutton", command=self._send_guides).pack(side="left", padx=(4, 0)) + self._row_slider(f, 1, "Opacidad", 0, 100, lambda v: self._send_guides(opacity=v), "{:.0f} %", key="guide_op") + self._row_slider(f, 2, "Área segura", 0, 100, lambda v: self._send_guides(safe=v), "{:.0f} %", key="guide_safe") + + card, f = self._card(p, "Pantalla") + card.grid(row=2, column=0, sticky="ew", padx=8, pady=(3, 6)) + f.columnconfigure(1, weight=1) + self._row_slider(f, 0, "Brillo LCD", 0, 1, self.cam.set_display_brightness, "{:.0%}", key="4.0") + self._row_slider(f, 1, "Nivel zebra", 0, 1, self.cam.set_zebra_level, "{:.0%}", key="4.2") + self._row_slider(f, 2, "Nivel peaking", 0, 1, self.cam.set_peaking_level, "{:.0%}", key="4.3") + trow = tk.Frame(f, bg=CARD) + trow.grid(row=3, column=0, columnspan=3, sticky="ew", pady=(4, 0)) + self._lbl(trow, "Timecode").pack(side="left") + self.tcsrc_var = tk.StringVar(value="Clip") + cb = ttk.Combobox(trow, textvariable=self.tcsrc_var, values=["Clip", "Timecode"], state="readonly", width=9) + cb.pack(side="left", padx=4) + cb.bind("<>", lambda e: self._send(self.cam.set_timecode_source(self.tcsrc_var.get() == "Timecode"), "Fuente de timecode")) + + def _build_page_aud(self, p): + card, f = self._card(p, "Niveles") + card.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 3)) + f.columnconfigure(1, weight=1) + self._row_slider(f, 0, "Micrófono", 0, 1, self.cam.set_mic_level, "{:.0%}", key="2.0") + self._row_slider(f, 1, "Canal 1", 0, 1, lambda v: self._audio_in_send(0, v), "{:.0%}", key="2.5.0") + self._row_slider(f, 2, "Canal 2", 0, 1, lambda v: self._audio_in_send(1, v), "{:.0%}", key="2.5.1") + self._row_slider(f, 3, "Auriculares", 0, 1, self.cam.set_headphone_level, "{:.0%}", key="2.1") + self._row_slider(f, 4, "Mezcla prog.", 0, 1, self.cam.set_headphone_mix, "{:.0%}", key="2.2") + self._row_slider(f, 5, "Altavoz", 0, 1, self.cam.set_speaker_level, "{:.0%}", key="2.3") + card, f = self._card(p, "Entrada") + card.grid(row=1, column=0, sticky="ew", padx=8, pady=(3, 6)) + row = tk.Frame(f, bg=CARD) + row.pack(fill="x") + self._lbl(row, "Tipo").pack(side="left") + self.audio_in_var = tk.StringVar(value=AUDIO_INPUTS[0]) + cb = ttk.Combobox(row, textvariable=self.audio_in_var, values=AUDIO_INPUTS, state="readonly", width=16) + cb.pack(side="left", padx=4) + cb.bind("<>", lambda e: self._send(self.cam.set_audio_input_type(AUDIO_INPUTS.index(self.audio_in_var.get())), "Entrada de audio")) + self.phantom_var = tk.BooleanVar() + ttk.Checkbutton(row, text="Phantom 48 V", variable=self.phantom_var, style="Toolbutton", + command=lambda: self._send(self.cam.set_phantom_power(self.phantom_var.get()), "Phantom")).pack(side="left", padx=(8, 0)) + + def _build_page_col(self, p): + card, f = self._card(p, "Corrección de color") + card.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 3)) + f.columnconfigure(0, weight=1) + hdr = tk.Frame(f, bg=CARD) + hdr.grid(row=0, column=0, columnspan=5, sticky="ew") + hdr.columnconfigure(0, minsize=52) + for i, (t, c) in enumerate((("R", "#ff6b6b"), ("G", "#6bff8a"), ("B", "#6bb3ff"), ("Y", FG))): + hdr.columnconfigure(i + 1, weight=1) + tk.Label(hdr, text=t, bg=CARD, fg=c, font=self.F_TITLE).grid(row=0, column=i + 1) + self._colour_sliders = {} + ranges = {0: ("Lift", -1.0, 1.0), 1: ("Gamma", -1.0, 1.0), 2: ("Gain", 0.0, 4.0), 3: ("Offset", -1.0, 1.0)} + for r, (par, (name, lo, hi)) in enumerate(ranges.items(), start=1): + rowf = tk.Frame(f, bg=CARD) + rowf.grid(row=r, column=0, columnspan=5, sticky="ew", pady=1) + rowf.columnconfigure(0, minsize=52) + self._lbl(rowf, name).grid(row=0, column=0, sticky="w") + for i in range(4): + rowf.columnconfigure(i + 1, weight=1) + s = DarkSlider(rowf, height=20, command=lambda v, p=par, i=i, lo=lo, hi=hi: self._colour_send(p, i, lo + v * (hi - lo))) + s.grid(row=0, column=i + 1, sticky="ew", padx=2) + s.set_enabled(True) + s.set((self._colour[par][i] - lo) / (hi - lo)) + self._colour_sliders[(par, i)] = (s, lo, hi) + Tooltip(rowf, f"{name}: arrastra cada canal; doble clic en Reset para volver a valores de fábrica") + f2 = tk.Frame(f, bg=CARD) + f2.grid(row=5, column=0, columnspan=5, sticky="ew", pady=(6, 0)) + f2.columnconfigure(1, weight=1) + self._row_slider(f2, 0, "Contraste", 0, 2, lambda v: self._contrast_send(1, v), "{:.2f}", key="8.4.1") + self._row_slider(f2, 1, "Pivote", 0, 1, lambda v: self._contrast_send(0, v), "{:.2f}", key="8.4.0") + self._row_slider(f2, 2, "Luma mix", 0, 1, self.cam.set_luma_mix, "{:.2f}", key="8.5") + self._row_slider(f2, 3, "Tono", -1, 1, lambda v: self._huesat_send(0, v), "{:+.2f}", key="8.6.0") + self._row_slider(f2, 4, "Saturación", 0, 2, lambda v: self._huesat_send(1, v), "{:.2f}", key="8.6.1") + ttk.Button(f, text="Reset a valores de fábrica", command=self._colour_reset).grid(row=6, column=0, columnspan=5, sticky="e", pady=(6, 0)) + for k, v in (("8.4.0", 0.5), ("8.4.1", 1.0), ("8.5", 1.0), ("8.6.0", 0.0), ("8.6.1", 1.0)): + self._sync_slider(k, v) + + def _build_page_set(self, p): + p.rowconfigure(4, weight=1) + card, f = self._card(p, "Cámara") + card.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 3)) + f.columnconfigure(0, weight=1) + self.device_var = tk.StringVar() + self.device_combo = ttk.Combobox(f, textvariable=self.device_var, state="readonly") + self.device_combo.grid(row=0, column=0, columnspan=4, sticky="ew") + self.btn_scan = ttk.Button(f, text="Buscar", command=self.scan) + self.btn_scan.grid(row=1, column=0, sticky="w", pady=(5, 0)) + self.btn_connect = ttk.Button(f, text="Conectar", command=self.toggle_connect) + self.btn_connect.grid(row=1, column=1, sticky="w", padx=(4, 0), pady=(5, 0)) + self.btn_pair = ttk.Button(f, text="Emparejar (PIN)", command=self.pair_camera) + self.btn_pair.grid(row=1, column=2, sticky="w", padx=(4, 0), pady=(5, 0)) + self.autoconnect_var = tk.BooleanVar(value=bool(self.cfg.get("autoconnect", True))) + ttk.Checkbutton(f, text="Al abrir", variable=self.autoconnect_var, style="Toolbutton", + command=lambda: (self.cfg.__setitem__("autoconnect", self.autoconnect_var.get()), save_config(self.cfg))).grid( + row=1, column=3, sticky="e", padx=(4, 0), pady=(5, 0)) + row = tk.Frame(f, bg=CARD) + row.grid(row=2, column=0, columnspan=4, sticky="ew", pady=(6, 0)) + b = ttk.Button(row, text="Sincronizar reloj", command=lambda: self._send(self.cam.sync_clock(), "Reloj")) + b.pack(side="left") + Tooltip(b, "Pone en la cámara la hora, fecha y zona horaria de este PC") + self._lbl(row, "AF auto cada").pack(side="left", padx=(10, 4)) + sp = ttk.Spinbox(row, from_=1, to=120, textvariable=self.af_every_var, width=4, + command=lambda: (self.cfg.__setitem__("af_every", self.af_every_var.get()), save_config(self.cfg))) + sp.pack(side="left") + self._lbl(row, "s", muted=True).pack(side="left", padx=(3, 0)) + ttk.Button(row, text="Apagar cámara", style="Danger.TButton", command=self.power_off).pack(side="right") + + card, f = self._card(p, "Presets propios") + card.grid(row=1, column=0, sticky="ew", padx=8, pady=3) + f.columnconfigure(0, weight=1) + self.preset_var = tk.StringVar() + self.preset_combo = ttk.Combobox(f, textvariable=self.preset_var, values=sorted(self.cfg.get("presets", {}).keys()), state="readonly") + self.preset_combo.grid(row=0, column=0, sticky="ew") + b = ttk.Button(f, text="Aplicar", command=self.preset_apply) + b.grid(row=0, column=1, padx=(4, 0)) + Tooltip(b, "Envía a la cámara ISO, obturador, WB, rango dinámico, LUT, peaking y formato guardados") + ttk.Button(f, text="Guardar actual…", command=self.preset_save).grid(row=0, column=2, padx=(4, 0)) + ttk.Button(f, text="Borrar", command=self.preset_delete).grid(row=0, column=3, padx=(4, 0)) + + card, f = self._card(p, "Panel para OBS (Docks → Custom Browser Docks)") + card.grid(row=2, column=0, sticky="ew", padx=8, pady=3) + f.columnconfigure(1, weight=1) + self.obs_dock_var = tk.BooleanVar(value=bool(self.cfg.get("obs_dock", True)) and self.dock_running) + b = ttk.Checkbutton(f, text="Activo", variable=self.obs_dock_var, style="Toolbutton", command=self.toggle_dock) + b.grid(row=0, column=0, sticky="w") + Tooltip(b, "Sirve una versión web del panel mini en este PC para acoplarla dentro de OBS") + self.dock_url_var = tk.StringVar() + tk.Entry(f, textvariable=self.dock_url_var, state="readonly", readonlybackground=FIELD, fg=FG, relief="flat", + font=self.F_MONO, highlightthickness=0).grid(row=0, column=1, sticky="ew", padx=(6, 0), ipady=3) + b = ttk.Button(f, text="Copiar URL", command=self.copy_dock_url) + b.grid(row=0, column=2, padx=(4, 0)) + b = ttk.Button(f, text="Añadir a OBS", command=self.add_dock_to_obs) + b.grid(row=0, column=3, padx=(4, 0)) + Tooltip(b, "Con OBS cerrado: registra el panel en OBS; al abrirlo aparece en Docks → Labmagic") + self._update_dock_line() + + card, f = self._card(p, "Tally") + card.grid(row=3, column=0, sticky="ew", padx=8, pady=3) + f.columnconfigure(1, weight=1) + self._row_slider(f, 0, "Frontal", 0, 1, lambda v: self.cam.set_tally(v, 1), "{:.0%}", key="5.1") + self._row_slider(f, 1, "Trasero", 0, 1, lambda v: self.cam.set_tally(v, 2), "{:.0%}", key="5.2") + + card, f = self._card(p, "Registro") + card.grid(row=4, column=0, sticky="nsew", padx=8, pady=(3, 6)) + f.columnconfigure(0, weight=1) + f.rowconfigure(0, weight=1) + self.log_text = tk.Text(f, height=5, wrap="word", font=self.F_MONO, state="disabled", bg="#0a0a0a", fg="#cfcfcf", + insertbackground=FG, relief="flat", highlightthickness=0, selectbackground="#3a3a3a", padx=6, pady=4) + self.log_text.grid(row=0, column=0, sticky="nsew") + sb = ttk.Scrollbar(f, orient="vertical", command=self.log_text.yview) + sb.grid(row=0, column=1, sticky="ns") + self.log_text.configure(yscrollcommand=sb.set) + barlog = tk.Frame(f, bg=CARD) + barlog.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(4, 0)) + self.show_all_var = tk.BooleanVar(value=False) + ttk.Checkbutton(barlog, text="No documentados", variable=self.show_all_var, style="Toolbutton").pack(side="left") + ttk.Button(barlog, text="Limpiar", command=self.clear_log).pack(side="right") + + # ---------------------------------------------------------- pestañas, plegado, encima + def _select_tab(self, key): + self.tab_var.set(key) + for k, (lab, line) in self._tab_widgets.items(): + lab.configure(fg=FG if k == key else MUTED) + line.configure(bg=FG if k == key else BAR) + self.pages[key].tkraise() + self.cfg["tab"] = key + save_config(self.cfg) + + def toggle_expand(self): + self.expanded = not self.expanded + self.cfg["expanded"] = self.expanded + save_config(self.cfg) + self._apply_expand() + + def _apply_expand(self, initial=False): + if self.expanded: + self.expand_frame.pack(fill="both", expand=True) + self.expand_btn.configure(text="⇲") + else: + self.expand_frame.pack_forget() + self.expand_btn.configure(text="⇱") + self.update_idletasks() + w = max(self.winfo_reqwidth(), 380) + h = self.winfo_reqheight() + self.minsize(w, h) + if initial: + pos = self.cfg.get("pos") + self.geometry(f"{w}x{h}" + (f"+{pos[0]}+{pos[1]}" if pos else "")) + else: + self.geometry(f"{w}x{h}") + + def _apply_topmost(self): + on = bool(self.topmost_var.get()) + self.cfg["topmost"] = on + save_config(self.cfg) + try: + self.attributes("-topmost", on) + except tk.TclError: + pass + + # ---------------------------------------------------------- franja de avisos + def notify(self, text, level="error", seconds=6): + self._notice = {"text": text, "level": level, "secs": seconds, "t": round(time.time(), 3)} + self.banner.configure(text=text, bg=REC if level == "error" else ("#8a5a00" if level == "warn" else "#1f4d2b")) + self.banner.pack(fill="x", after=self.banner.master.winfo_children()[0]) + if self._banner_id: + try: + self.after_cancel(self._banner_id) + except tk.TclError: + pass + self._banner_id = self.after(seconds * 1000, self.banner.pack_forget) + + # ---------------------------------------------------------- selectores estilo cámara + def _open_picker(self, anchor, title, width=330): + if self._picker is not None: + self._picker.close() + if not self.connected: + self.notify("Conecta la cámara para cambiar valores", "warn", 3) + return None + self._picker = Picker(self, anchor, title, width) + return self._picker + + def _pick_iso(self, anchor): + pk = self._open_picker(anchor, "ISO") + if not pk: + return + cur = int(self.iso_var.get() or 0) if self.iso_var.get().isdigit() else None + pk.set_value(str(cur) if cur else "—") + full = [100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600] + thirds = [v for v in ISO_VALUES if v not in full] + + def choose(v): + self._send(self.cam.set_iso(v), "ISO") + pk.close() + + pk.chips([(str(v), v) for v in full], cur, choose, per_row=5) + tk.Frame(pk.body, bg="#2a2a2a", height=1).pack(fill="x", pady=4) + pk.chips([(str(v), v) for v in thirds], cur, choose, per_row=6, dim_set=set(thirds)) + tk.Label(pk.body, text="Nativos de la Pocket 4K: 400 y 3200 · tercios en gris", bg="#161616", fg=MUTED, + font=self.F_SMALL).pack(anchor="w", pady=(4, 0)) + + def _pick_shutter(self, anchor): + pk = self._open_picker(anchor, "Obturador") + if not pk: + return + try: + cur_a = float(self.angle_var.get()) + except ValueError: + cur_a = None + pk.set_value(f"{cur_a:g}°" if cur_a else (f"1/{self.speed_var.get()}" if self.speed_var.get() else "—")) + + def choose_a(a): + self._send(self.cam.set_shutter_angle(a), "Obturador") + pk.close() + + def choose_s(s): + self._send(self.cam.set_shutter_speed(s), "Obturador") + pk.close() + + tk.Label(pk.body, text="ÁNGULO", bg="#161616", fg=MUTED, font=self.F_SMALL).pack(anchor="w") + main = [45, 90, 172.8, 180, 270, 360] + pk.chips([(f"{a:g}°", a) for a in main], cur_a, choose_a, per_row=6) + pk.chips([(f"{a:g}°", a) for a in SHUTTER_ANGLES if a not in main], cur_a, choose_a, per_row=6, + dim_set={a for a in SHUTTER_ANGLES if a not in main}) + tk.Label(pk.body, text="VELOCIDAD 1/x", bg="#161616", fg=MUTED, font=self.F_SMALL).pack(anchor="w", pady=(6, 0)) + cur_s = int(self.speed_var.get()) if self.speed_var.get().isdigit() else None + pk.chips([(f"1/{s}", s) for s in SHUTTER_SPEEDS], cur_s, choose_s, per_row=7) + + def _pick_iris(self, anchor): + pk = self._open_picker(anchor, "Iris") + if not pk: + return + pk.set_value(self.aperture_lbl.cget("text")) + + def choose(f): + self._send(self.cam.set_aperture_fstop(f), "Apertura") + pk.close() + + pk.chips([(f"f/{f:g}", f) for f in FSTOPS], None, choose, per_row=7) + row = tk.Frame(pk.body, bg="#161616") + row.pack(fill="x", pady=(8, 0)) + tk.Label(row, text="abierto", bg="#161616", fg=MUTED, font=self.F_SMALL).pack(side="left") + s = DarkSlider(row, bg="#161616", command=lambda v: self._on_slider("aperture", v)) + s.pack(side="left", fill="x", expand=True, padx=6) + s.set_enabled(True) + s.set(self.aperture_value or 0.0) + tk.Label(row, text="cerrado", bg="#161616", fg=MUTED, font=self.F_SMALL).pack(side="left") + pk.chip(pk.body, "AUTO IRIS", False, lambda: (self._send(self.cam.auto_iris(), "Auto iris"), pk.close())).pack(anchor="e", pady=(8, 0)) + + def _pick_wb(self, anchor): + pk = self._open_picker(anchor, "Balance de blancos", 360) + if not pk: + return + k0 = self.wb_kelvin or int(self.wb_var.get() or 5600) + t0 = self.wb_tint if self.wb_tint is not None else int(self.tint_var.get() or 0) + state = {"k": k0, "t": t0} + + def refresh(): + pk.set_value(f"{state['k']}K {state['t']:+d}") + + def send(): + self.wb_var.set(state["k"]) + self.tint_var.set(state["t"]) + self._throttle("wb", lambda: self._send(self.cam.set_white_balance(state["k"], state["t"]), "Balance de blancos")) + + refresh() + row = tk.Frame(pk.body, bg="#161616") + row.pack(fill="x") + tk.Label(row, text="2500", bg="#161616", fg=MUTED, font=self.F_SMALL).pack(side="left") + ks = DarkSlider(row, bg="#161616", command=lambda v: (state.__setitem__("k", int(round((2500 + v * 7500) / 50) * 50)), refresh(), send())) + ks.pack(side="left", fill="x", expand=True, padx=6) + ks.set_enabled(True) + ks.set((k0 - 2500) / 7500) + tk.Label(row, text="10000", bg="#161616", fg=MUTED, font=self.F_SMALL).pack(side="left") + ticks = tk.Frame(pk.body, bg="#161616") + ticks.pack(fill="x", padx=36) + for k in (3200, 4000, 5600, 6500, 7500): + tk.Label(ticks, text=str(k), bg="#161616", fg=DIM, font=("Segoe UI", 7)).pack(side="left", expand=True) + prow = tk.Frame(pk.body, bg="#161616") + prow.pack(fill="x", pady=(6, 0)) + for c in range(3): + prow.columnconfigure(c, weight=1, uniform="wb") + for i, (text, k, t) in enumerate(WB_PRESET_CHIPS): + def go(k=k, t=t): + state["k"], state["t"] = k, t + ks.set((k - 2500) / 7500) + ts.set((t + 50) / 100) + refresh() + send() + pk.chip(prow, text, state["k"] == k, go).grid(row=i // 3, column=i % 3, sticky="ew", padx=2, pady=2) + pk.chip(prow, "AUTO WB", False, lambda: (self._send(self.cam.auto_white_balance(), "Auto WB"), pk.close())).grid( + row=1, column=2, sticky="ew", padx=2, pady=2) + trow = tk.Frame(pk.body, bg="#161616") + trow.pack(fill="x", pady=(8, 0)) + tk.Label(trow, text="Tinte", bg="#161616", fg=FG, font=self.F_UI).pack(side="left") + ts = DarkSlider(trow, bg="#161616", command=lambda v: (state.__setitem__("t", int(round(v * 100 - 50))), refresh(), send())) + ts.pack(side="left", fill="x", expand=True, padx=6) + ts.set_enabled(True) + ts.set((t0 + 50) / 100) + for txt, d in (("−", -1), ("+", 1)): + pk.chip(trow, txt, False, lambda d=d: (state.__setitem__("t", max(-50, min(50, state["t"] + d))), ts.set((state["t"] + 50) / 100), refresh(), send()), width=2).pack(side="left", padx=(3, 0)) + + # ---------------------------------------------------------- deslizadores genéricos y sincronía + def _row_slider(self, parent, row, label, lo, hi, sender, fmt="{:.2f}", key=None): + self._lbl(parent, label).grid(row=row, column=0, sticky="w", pady=1) + s = DarkSlider(parent, height=22, command=lambda v: self._slider_send(key, lo + v * (hi - lo), sender, fmt)) + s.grid(row=row, column=1, sticky="ew", padx=4, pady=1) + s.set_enabled(True) + val = self._lbl(parent, "—", width=6, anchor="e") + val.grid(row=row, column=2, sticky="e") + if key: + self._pslider[key] = (s, val, lo, hi, fmt) + return s, val + + def _slider_send(self, key, real, sender, fmt): + if key in self._pslider: + self._pslider[key][1].configure(text=fmt.format(real)) + self._throttle(key or str(id(sender)), lambda r=real: self._send(sender(r), key or "ajuste")) + + def _sync_slider(self, key, real): + if key in self._pslider: + s, val, lo, hi, fmt = self._pslider[key] + s.set((real - lo) / (hi - lo) if hi != lo else 0) + val.configure(text=fmt.format(real)) + + def _colour_send(self, par, idx, real): + self._colour[par][idx] = real + self._throttle(f"col{par}", lambda p=par: self._send(self.cam.set_colour_rgby(p, self._colour[p]), "Color")) + + def _contrast_send(self, idx, real): + self._contrast[idx] = real + self._throttle("contrast", lambda: self._send(self.cam.set_contrast(*self._contrast), "Contraste")) + + def _huesat_send(self, idx, real): + self._huesat[idx] = real + self._throttle("huesat", lambda: self._send(self.cam.set_hue_sat(*self._huesat), "Tono/saturación")) + + def _colour_reset(self): + self._send(self.cam.reset_colour_correction(), "Reset color") + self._colour = {0: [0.0] * 4, 1: [0.0] * 4, 2: [1.0] * 4, 3: [0.0] * 4} + for (par, i), (s, lo, hi) in self._colour_sliders.items(): + s.set((self._colour[par][i] - lo) / (hi - lo)) + self._contrast, self._huesat = [0.5, 1.0], [0.0, 1.0] + for k, v in (("8.4.0", 0.5), ("8.4.1", 1.0), ("8.5", 1.0), ("8.6.0", 0.0), ("8.6.1", 1.0)): + self._sync_slider(k, v) + + def _audio_in_send(self, idx, real): + self._audio_in[idx] = real + self._throttle("audioin", lambda: self._send(self.cam.set_input_levels(*self._audio_in), "Niveles de entrada")) + + def _send_guides(self, opacity=None, safe=None): + if opacity is not None: + self._guides[1] = int(opacity) + if safe is not None: + self._guides[2] = int(safe) + self._guides[0] = GUIDE_STYLES.index(self.guide_style_var.get()) if self.guide_style_var.get() in GUIDE_STYLES else 0 + self._guides[3] = sum(bit for bit, v in self.grid_vars.items() if v.get()) + self._throttle("guides", lambda: self._send(self.cam.set_frame_guides(*self._guides), "Guías")) + + def _send_scene_tags(self): + tag = SHOT_TAGS.index(self.shot_var.get()) - 1 + self._send(self.cam.set_scene_tags(tag, 1 if self.intext_var.get() == "Exterior" else 0, + 1 if self.daynight_var.get() == "Día" else 0), "Etiquetas de escena") + + def _send_take(self): + try: + n = int(self.take_var.get()) + except (tk.TclError, ValueError): + return + self._send(self.cam.set_take(n, TAKE_TAGS.index(self.taketag_var.get()) - 1), "Toma") + + # ---------------------------------------------------------- presets propios + def _preset_snapshot(self): + return { + "iso": self.iso_var.get(), "angle": self.angle_var.get(), "speed": self.speed_var.get(), + "wb": self.wb_var.get(), "tint": self.tint_var.get(), "dr": self.dr_var.get(), + "lut": self.lut_var.get(), "lut_on": self.lut_on_var.get(), "peak": self.peak_var.get(), + "peak_colour": self.peak_colour, "codec": self.codec_sel_var.get(), "res": self.res_var.get(), + "fps": self.fps_var.get(), "sfps": self.sfps_var.get(), "window": self.window_var.get(), + } + + def preset_save(self): + name = simpledialog.askstring("Guardar preset", "Nombre del preset (p. ej. Entrevista, Producto):", parent=self) + if not name: + return + presets = self.cfg.setdefault("presets", {}) + presets[name.strip()] = self._preset_snapshot() + save_config(self.cfg) + self.preset_combo.configure(values=sorted(presets.keys())) + self.preset_var.set(name.strip()) + self.notify(f"Preset «{name.strip()}» guardado", "ok", 3) + + def preset_delete(self): + name = self.preset_var.get() + presets = self.cfg.get("presets", {}) + if name in presets: + del presets[name] + save_config(self.cfg) + self.preset_combo.configure(values=sorted(presets.keys())) + self.preset_var.set("") + + def preset_apply(self): + name = self.preset_var.get() + pr = self.cfg.get("presets", {}).get(name) + if not pr or not self.connected: + self.notify("Elige un preset y conecta la cámara", "warn", 3) + return + + async def go(): + if pr.get("iso", "").isdigit(): + await self.cam.set_iso(int(pr["iso"])) + if pr.get("angle"): + await self.cam.set_shutter_angle(float(pr["angle"])) + elif str(pr.get("speed", "")).isdigit(): + await self.cam.set_shutter_speed(int(pr["speed"])) + await self.cam.set_white_balance(int(pr.get("wb", 5600)), int(pr.get("tint", 0))) + if pr.get("dr") in DR_MODES: + await self.cam.set_dynamic_range(DR_MODES.index(pr["dr"])) + if pr.get("lut") in LUTS: + await self.cam.set_display_lut(LUTS.index(pr["lut"]), bool(pr.get("lut_on"))) + await self.cam.set_peaking(bool(pr.get("peak")), int(pr.get("peak_colour", 0)), self.zebra_var.get(), + self.false_var.get(), self._targets()) + codec = next((c for c in CODEC_CHOICES if c[0] == pr.get("codec")), None) + res = next((r for r in RESOLUTIONS if r[0] == pr.get("res")), None) + fps, m = fps_parse(pr.get("fps", "")) + if codec and res and fps: + await self.cam.set_codec(codec[1], codec[2]) + sf, sm = (fps_parse(pr["sfps"]) if pr.get("sfps") not in (None, "", "igual") else (0, False)) + await self.cam.set_recording_format(fps, sf, res[1], res[2], m_rate=m, sensor_m_rate=sm, + off_speed=bool(sf), windowed=bool(pr.get("window"))) + + self._send(go(), f"Preset {name}") + self.notify(f"Aplicando preset «{name}»", "ok", 3) + + # ---------------------------------------------------------- estado de controles y REC + def _update_rec_info(self): + bits = [t for t in (self.format_var.get(), self.codec_var.get()) if t] + self.rec_info_var.set(" · ".join(bits)) + + # ---------------------------------------------------------- sincronía de parámetros nuevos + def _sync_extra(self, cat, par, values): + key = (cat, par) + if cat == 2 and values: + if par in (0, 1, 2, 3): + self._sync_slider(f"2.{par}", float(values[0])) + elif par == 4 and 0 <= values[0] < len(AUDIO_INPUTS): + self.audio_in_var.set(AUDIO_INPUTS[values[0]]) + elif par == 5 and len(values) >= 2: + self._audio_in = [float(values[0]), float(values[1])] + self._sync_slider("2.5.0", self._audio_in[0]) + self._sync_slider("2.5.1", self._audio_in[1]) + elif par == 6: + self.phantom_var.set(bool(values[0])) + elif key == (3, 3) and len(values) >= 4: + self._guides = [int(v) for v in values[:4]] + if 0 <= self._guides[0] < len(GUIDE_STYLES): + self.guide_style_var.set(GUIDE_STYLES[self._guides[0]]) + self._sync_slider("guide_op", self._guides[1]) + self._sync_slider("guide_safe", self._guides[2]) + for bit, v in self.grid_vars.items(): + v.set(bool(self._guides[3] & bit)) + elif cat == 4 and values: + if par in (0, 2, 3): + self._sync_slider(f"4.{par}", float(values[0])) + elif par == 7: + self.tcsrc_var.set("Timecode" if values[0] else "Clip") + elif cat == 5 and values: + if par == 0: + self._sync_slider("5.1", float(values[0])) + self._sync_slider("5.2", float(values[0])) + elif par in (1, 2): + self._sync_slider(f"5.{par}", float(values[0])) + elif cat == 8 and values: + if par in (0, 1, 2, 3) and len(values) >= 4: + self._colour[par] = [float(v) for v in values[:4]] + for i in range(4): + s, lo, hi = self._colour_sliders[(par, i)] + s.set((self._colour[par][i] - lo) / (hi - lo)) + elif par == 4 and len(values) >= 2: + self._contrast = [float(values[0]), float(values[1])] + self._sync_slider("8.4.0", self._contrast[0]) + self._sync_slider("8.4.1", self._contrast[1]) + elif par == 5: + self._sync_slider("8.5", float(values[0])) + elif par == 6 and len(values) >= 2: + self._huesat = [float(values[0]), float(values[1])] + self._sync_slider("8.6.0", self._huesat[0]) + self._sync_slider("8.6.1", self._huesat[1]) + elif key == (1, 8) and values and 0 <= values[0] < len(SHARPEN): + self.sharp_var.set(SHARPEN[values[0]]) + elif key == (1, 13) and values: + self.gain_var.set(int(values[0])) + elif key == (0, 6) and values: + self.ois_var.set(bool(values[0])) + elif key == (10, 1) and len(values) >= 3: + self.loop_var.set(bool(values[2] & 1)) + self.playall_var.set(bool(values[2] & 2)) + elif cat == 12: + if par == 0 and values: + self.reel_var.set(int(values[0])) + elif par == 1 and len(values) >= 3: + if -1 <= values[0] <= 5: + self.shot_var.set(SHOT_TAGS[values[0] + 1]) + self.intext_var.set("Exterior" if values[1] else "Interior") + self.daynight_var.set("Día" if values[2] else "Noche") + elif par == 2 and values and isinstance(values[0], str): + self.scene_var.set(values[0]) + elif par == 3 and len(values) >= 2: + self.take_var.set(int(values[0])) + if -1 <= values[1] <= 2: + self.taketag_var.set(TAKE_TAGS[values[1] + 1]) + elif par == 4 and values: + self.goodtake_var.set(bool(values[0])) + elif par in (5, 6, 7, 8) and values and isinstance(values[0], str): + {5: self.camid_var, 6: self.operator_var, 7: self.director_var, 8: self.project_var}[par].set(values[0]) + + # ------------------------------------------------------------ helpers + def log(self, text): + stamp = time.strftime("%H:%M:%S") + # Copia en labmagic.log (se reinicia al arrancar) para diagnosticar sin la ventana + try: + with open(LOG_PATH, "a", encoding="utf-8") as fh: + fh.write(f"{stamp} {text}\n") + except OSError: + pass + self.log_text.configure(state="normal") + self.log_text.insert("end", f"{stamp} {text}\n") + self.log_text.see("end") + lines = int(self.log_text.index("end-1c").split(".")[0]) + if lines > 500: + self.log_text.delete("1.0", f"{lines - 500}.0") + self.log_text.configure(state="disabled") + + def clear_log(self): + self.log_text.configure(state="normal") + self.log_text.delete("1.0", "end") + self.log_text.configure(state="disabled") + + def _set_led(self, color): + self.led.itemconfigure(self.led_item, fill=color) + + def _draw_rec(self, hover=False): + c = self.rec_canvas + c.delete("all") + ring = "#5a5a5a" if (hover and self.connected) else "#333333" + c.create_oval(3, 3, 45, 45, outline=ring, width=2) + color = REC if self.connected else "#5a2022" + if self.recording: + c.create_rectangle(15, 15, 33, 33, fill=color, outline=color) + else: + c.create_oval(9, 9, 39, 39, fill=color, outline=color) + + def _blink_tick(self): + self._blink = not self._blink + if self.recording: + self.rec_dot.configure(fg=REC if self._blink else HUD) + else: + self.rec_dot.configure(fg=HUD) + self.after(600, self._blink_tick) + + def _set_controls_enabled(self, enabled): + state = "normal" if enabled else "disabled" + if not enabled: + self.aperture_value = None + self.focus_value = None + self._lens_type = None + self._focus_reported = False + for b in self.aperture_nudge_btns: + b.configure(state="disabled") + for s in (self.focus_slider, self.aperture_slider): + s.set_enabled(enabled) + for w in (self.btn_af, self.dr_combo, self.ae_combo, self.lut_combo, self.lut_toggle, self.codec_combo, + self.res_combo, self.fps_combo, self.sfps_combo, self.window_toggle, self.btn_format, + self.sharp_combo, self.gain_spin, self.ois_toggle): + try: + if isinstance(w, ttk.Combobox): + w.configure(state="readonly" if enabled else "disabled") + else: + w.configure(state=state) + except tk.TclError: + pass + self._update_focus_mode() + self._draw_rec() + + def _send(self, coro, what="", popup=False): + fut = self.worker.submit(coro) + + def done(f): + try: + f.result() + except Exception as e: # noqa: BLE001 + msg = str(e).strip() + if not msg: + # bleak lanza TimeoutError sin texto cuando la cámara no responde (apagada, dormida o lejos) + msg = ("la cámara no responde: comprueba que esté encendida, con Bluetooth activado " + "(Ajustes > Setup) y cerca del PC" if isinstance(e, TimeoutError) else type(e).__name__) + self.events.put({"kind": "error", "text": f"{what}: {msg}", "popup": popup, "t": time.time()}) + + fut.add_done_callback(done) + return fut + + def _on_slider(self, key, value): + if key == "focus": + self.focus_value = value + self.focus_lbl.configure(text=f"{value:.2f}") + self._throttle(key, lambda v=value: self._send(self.cam.set_focus(v), "Foco")) + elif key == "aperture": + self.aperture_value = value + self._throttle(key, lambda v=value: self._send(self.cam.set_aperture_norm(v), "Apertura")) + elif key == "zoom": + self.zoom_value = value + self._throttle(key, lambda v=value: self._send(self.cam.set_zoom_norm(v), "Zoom")) + + def _throttle(self, key, fn): + self._pending[key] = fn + if not self._flush_scheduled: + self._flush_scheduled = True + self.after(120, self._flush) + + def _flush(self): + self._flush_scheduled = False + pending, self._pending = self._pending, {} + for fn in pending.values(): + fn() + + # ------------------------------------------------ menús del HUD + def _set_wb(self, kelvin, tint): + k = kelvin if kelvin is not None else (self.wb_kelvin or int(self.wb_var.get() or 5600)) + t = tint if tint is not None else (self.wb_tint if self.wb_tint is not None else 0) + self.wb_var.set(k) + self.tint_var.set(t) + self._send(self.cam.set_white_balance(k, t), "Balance de blancos") + + # ------------------------------------------------ emparejamiento y peaking + PEAK_COLOURS = [("Rojo", 0), ("Verde", 1), ("Azul", 2), ("Blanco", 3), ("Negro", 4)] + + def _peak_colour_text(self): + for name, idx in self.PEAK_COLOURS: + if idx == self.peak_colour: + return f"{name} ▾" + return "Color ▾" + + def _menu_peak_colour(self): + m = tk.Menu(self, tearoff=0, bg="#1e1e1e", fg=FG, activebackground=FG, activeforeground="#000000", + bd=0, relief="flat", font=self.F_UI, activeborderwidth=0) + for name, idx in self.PEAK_COLOURS: + m.add_command(label=name, command=lambda idx=idx: self._set_peak_colour(idx)) + x = self.peak_colour_btn.winfo_rootx() + y = self.peak_colour_btn.winfo_rooty() + self.peak_colour_btn.winfo_height() + try: + m.tk_popup(x, y) + finally: + m.grab_release() + + def _set_peak_colour(self, idx): + self.peak_colour = idx + self.cfg["peak_colour"] = idx + save_config(self.cfg) + self.peak_colour_btn.configure(text=self._peak_colour_text()) + if self.connected and self.peak_var.get(): + self.apply_peaking() + + def apply_peaking(self): + """Enciende o apaga el focus assist con líneas de color (misma variable en ambos conmutadores).""" + if not self.connected: + self.peak_var.set(False) + self.log("Conecta la cámara para activar el peaking.") + return + self._send(self.cam.set_peaking(self.peak_var.get(), self.peak_colour, self.zebra_var.get(), + self.false_var.get(), self._targets()), "Peaking") + + def _offer_close_settings(self): + """Windows no soltó el enlace: ofrecer cerrar la ventana de Configuración y reconectar.""" + if not self.connected: + return + if messagebox.askyesno( + "Leer la configuración de la cámara", + "La cámara solo envía todos sus valores (ISO, obturador, formato...) cuando el enlace " + "Bluetooth es nuevo, y Windows no lo ha soltado: casi siempre es la ventana de " + "Configuración > Bluetooth.\n\n¿Cerrar esa ventana de Windows y reconectar ahora?", + ): + self.reconnect_fresh() + + def reconnect_fresh(self): + """Cierra Configuración de Windows (si está abierta), corta el enlace y vuelve a conectar.""" + import subprocess + + try: + si = subprocess.STARTUPINFO() + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + subprocess.run(["taskkill", "/IM", "SystemSettings.exe", "/F"], capture_output=True, timeout=10, + startupinfo=si) + self.log("Ventana de Configuración de Windows cerrada.") + except Exception as e: # noqa: BLE001 + self.log(f"No se pudo cerrar Configuración de Windows: {e}") + addr = self.cfg.get("last_address") + name = None + for d in self.devices: + if d["address"].upper() == (addr or "").upper(): + name = d.get("name") + if not addr: + return + + async def go(): + await self.cam.disconnect() + await self.cam._wait_link_down(addr, 25.0) + await self.cam.connect(addr, name) + + self.btn_connect.configure(state="disabled") + self._send(go(), "Reconexión", popup=True) + + def pair_camera(self): + if self.connected: + self._send(self.cam.disconnect(), "Desconectar") + idx = self.device_combo.current() + if idx < 0 or idx >= len(self.devices): + messagebox.showinfo("Labmagic", "Primero busca y elige una cámara en la lista.") + return + d = self.devices[idx] + if not messagebox.askokcancel( + "Emparejar con la cámara", + "Se quitará el emparejamiento anterior de Windows y se pedirá uno nuevo.\n\n" + "En la cámara, ten el Bluetooth activado (Ajustes > Setup > Bluetooth). Cuando Windows " + "lo solicite, la cámara mostrará un PIN de 6 cifras en pantalla: escríbelo en la ventana " + "que aparecerá aquí.\n\n¿Continuar?", + ): + return + self.cfg["last_address"] = d["address"] + save_config(self.cfg) + self.btn_pair.configure(state="disabled") + self.btn_connect.configure(state="disabled") + self._send(self.cam.pair(d["address"], self._ask_pin_blocking, d.get("name")), "Emparejar", popup=True) + + def _ask_pin_blocking(self): + """Se llama desde un hilo auxiliar: pide el PIN a la ventana y espera la respuesta.""" + reply = queue.Queue() + self.events.put({"kind": "ask_pin", "reply": reply, "t": time.time()}) + try: + return reply.get(timeout=240) + except queue.Empty: + return None + + def _show_pin_dialog(self, reply): + win = tk.Toplevel(self) + win.title("PIN de la cámara") + win.configure(bg=CARD) + win.resizable(False, False) + win.transient(self) + win.grab_set() + tk.Label(win, text="Escribe el PIN que muestra la cámara en su pantalla", bg=CARD, fg=FG, + font=self.F_BIG).pack(padx=24, pady=(20, 6)) + tk.Label(win, text="Son 6 cifras. Tienes unos 30 segundos antes de que caduque.", bg=CARD, fg=MUTED, + font=self.F_UI).pack(padx=24) + var = tk.StringVar() + entry = tk.Entry(win, textvariable=var, font=self.F_TC, width=8, justify="center", bg=FIELD, fg=FG, + insertbackground=FG, relief="flat", highlightthickness=1, highlightbackground=BORDER, + highlightcolor=FG) + entry.pack(padx=24, pady=16, ipady=6) + done = {"sent": False} + + def finish(value): + if not done["sent"]: + done["sent"] = True + reply.put(value) + try: + win.grab_release() + win.destroy() + except tk.TclError: + pass + + row = tk.Frame(win, bg=CARD) + row.pack(pady=(0, 20)) + ttk.Button(row, text="Emparejar", style="Big.TButton", + command=lambda: finish(var.get().strip() or None)).pack(side="left", padx=6) + ttk.Button(row, text="Cancelar", command=lambda: finish(None)).pack(side="left", padx=6) + entry.bind("", lambda e: finish(var.get().strip() or None)) + win.protocol("WM_DELETE_WINDOW", lambda: finish(None)) + win.update_idletasks() + x = self.winfo_rootx() + (self.winfo_width() - win.winfo_width()) // 2 + y = self.winfo_rooty() + (self.winfo_height() - win.winfo_height()) // 3 + win.geometry(f"+{max(0, x)}+{max(0, y)}") + entry.focus_set() + try: + self._dark_title_bar_for(win) + except Exception: # noqa: BLE001 + pass + + def _dark_title_bar_for(self, win): + win.update_idletasks() + hwnd = ctypes.windll.user32.GetParent(win.winfo_id()) + value = ctypes.c_int(1) + for attr in (20, 19): + if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, attr, ctypes.byref(value), 4) == 0: + break + + # ------------------------------------------------------------ acciones + def scan(self): + if not self.connected: + self.btn_scan.configure(state="disabled") + self.device_combo.set("Buscando...") + self._send(self.cam.find_cameras(4.0), "Búsqueda") + + def toggle_connect(self): + if self.connected: + self._send(self.cam.disconnect(), "Desconectar") + return + idx = self.device_combo.current() + if idx < 0 or idx >= len(self.devices): + messagebox.showinfo("Labmagic", "Primero busca y elige una cámara en la lista.") + return + addr = self.devices[idx]["address"] + name = self.devices[idx].get("name") + self.cfg["last_address"] = addr + save_config(self.cfg) + self.btn_connect.configure(state="disabled") + self._send(self.cam.connect(addr, name), "Conexión", popup=True) + + def autofocus(self): + if not self.connected: + return + self._send(self.cam.autofocus(), "Autofoco") + # Tras un AF la posición real del objetivo ya no es la última enviada (la cámara no la informa) + if not self._focus_reported: + self.focus_value = None + self.focus_lbl.configure(text="—") + # La cámara no confirma el AF: damos respuesta visual al enviar y recordamos las condiciones + try: + self.btn_af.configure(text="AF ● enviado") + self.after(900, lambda: self.btn_af.configure(text="AUTOFOCO (AF)")) + except tk.TclError: + pass + if not self._af_hint_shown: + self._af_hint_shown = True + self.log("AF enviado (paquete oficial 0.1). La cámara no confirma si el objetivo enfoca: el " + "interruptor del objetivo debe estar en AF y el adaptador debe admitir AF con ese " + "objetivo. Prueba: si el botón Focus de la propia cámara tampoco enfoca, es el " + "adaptador/objetivo, no la app.") + + def _space_af(self, event): + """Barra espaciadora = AF, salvo cuando se está escribiendo en un campo.""" + w = self.focus_get() + if isinstance(w, (tk.Entry, tk.Text, ttk.Entry, ttk.Spinbox, ttk.Combobox)): + return None + self.autofocus() + return "break" + + def _arrow_focus(self, event, delta): + """Flechas del teclado = pasos de foco, salvo cuando se está escribiendo en un campo.""" + w = self.focus_get() + if isinstance(w, (tk.Entry, tk.Text, ttk.Entry, ttk.Spinbox, ttk.Combobox)): + return None + if not self.connected: + return None + self.nudge_focus(delta) + return "break" + + def _af_tick(self): + if self.af_auto_var.get() and self.connected: + self.autofocus() + try: + secs = max(1, int(self.af_every_var.get())) + except (tk.TclError, ValueError): + secs = 5 + self.after(secs * 1000, self._af_tick) + + def _update_focus_mode(self): + """Objetivos EF por adaptador: la cámara solo admite AF; el foco manual no está disponible.""" + lens = (self._lens_type or "").upper() + ef = bool(lens) and ("EF" in lens or "X " in lens) and not self._focus_reported + manual_ok = self.connected + self.focus_slider.set_enabled(manual_ok) + for b in self.focus_nudge_btns: + b.configure(state="normal" if manual_ok else "disabled") + if ef: + self.focus_note_var.set("EF: tras AF el foco marca — · espacio = AF · ← → foco") + else: + self.focus_note_var.set("espacio = AF · ← → foco · Mayús = pasos grandes") + + def nudge_focus(self, delta): + if not self.connected: + return + if self.focus_value is None: + self._send(self.cam.offset_focus(delta), "Foco") + return + v = max(0.0, min(1.0, self.focus_value + delta)) + self.focus_value = v + self.focus_slider.set(v) + self.focus_lbl.configure(text=f"{v:.2f}") + self._send(self.cam.set_focus(v), "Foco") + + def nudge_aperture(self, delta): + if not self.connected: + return + if self.aperture_value is None: + self.log("Aún no se conoce la apertura actual: usa el deslizador o el menú IRIS del HUD.") + return + v = max(0.0, min(1.0, self.aperture_value + delta)) + self.aperture_value = v + self.aperture_slider.set(v) + self._send(self.cam.set_aperture_norm(v), "Apertura") + + def apply_wb(self): + try: + k, t = int(self.wb_var.get()), int(self.tint_var.get()) + except (tk.TclError, ValueError): + return + self._send(self.cam.set_white_balance(k, t), "Balance de blancos") + + def apply_wb_preset(self, _event=None): + name = self.wb_preset_var.get() + for label, k, t in WB_PRESETS: + if label == name: + self.wb_var.set(k) + self.tint_var.set(t) + self.apply_wb() + break + + def apply_lut(self): + name = self.lut_var.get() + idx = LUTS.index(name) if name in LUTS else 0 + self._send(self.cam.set_display_lut(idx, self.lut_on_var.get()), "LUT") + + def _targets(self): + """Bits de pantallas destino según el selector Aplicar en (HDMI / LCD / ambas).""" + t = self.target_var.get() + if t == "hdmi": + return Camera.OUT_HDMI + if t == "lcd": + return Camera.OUT_LCD + return Camera.OUT_LCD | Camera.OUT_HDMI + + def _on_target_change(self): + self.cfg["monitor_target"] = self.target_var.get() + save_config(self.cfg) + if self.connected: + self.apply_tools() + self.apply_overlays() + + def apply_tools(self): + if not self.connected: + return + self._send(self.cam.set_tools(self.zebra_var.get(), self.peak_var.get(), self.false_var.get(), + self._targets()), "Ayudas") + + def apply_overlays(self): + if not self.connected: + return + self._send(self.cam.set_overlays(self.status_ov_var.get(), self.guides_ov_var.get(), + self.clean_ov_var.get(), self._targets()), "Overlays") + + def apply_format(self): + if not self.connected: + return + fps, m_rate = fps_parse(self.fps_var.get()) + if not fps: + self.log("Elige los fotogramas por segundo.") + return + res = next((r for r in RESOLUTIONS if r[0] == self.res_var.get()), None) + if res is None: + self.log("Elige una resolución.") + return + sensor_label = self.sfps_var.get() + if sensor_label and sensor_label != "igual": + sfps, s_m_rate = fps_parse(sensor_label) + off_speed = True + else: + sfps, s_m_rate, off_speed = 0, False, False + codec = next((c for c in CODEC_CHOICES if c[0] == self.codec_sel_var.get()), None) + if codec is not None: + self._send(self.cam.set_codec(codec[1], codec[2]), "Códec") + self._send(self.cam.set_recording_format(fps, sfps, res[1], res[2], m_rate=m_rate, sensor_m_rate=s_m_rate, + off_speed=off_speed, windowed=self.window_var.get()), "Formato") + self.log(f"-> Formato pedido: {res[1]}×{res[2]} · {self.fps_var.get()} fps" + + (f" · sensor {sensor_label} fps" if off_speed else "") + + (" · ventana" if self.window_var.get() else "") + + (f" · {codec[0]}" if codec else "")) + + def toggle_record(self): + if not self.connected: + return + self._send(self.cam.set_recording(not self.recording), "Grabación") + + def power_off(self): + if not self.connected: + return + if messagebox.askyesno("Apagar cámara", "¿Apagar la cámara ahora? Tendrás que encenderla a mano."): + self._send(self.cam.power_off(), "Apagar") + + # -------------------------------------------------------- eventos BLE + def _poll_events(self): + try: + while True: + ev = self.events.get_nowait() + try: + self._handle_event(ev) + except Exception as e: # noqa: BLE001 + self.log(f"Error interno procesando {ev.get('kind')}: {e}") + except queue.Empty: + pass + if self.connected and self._connect_time and not self._payload_warned: + if time.time() - self._connect_time > 4 and self._payload_count < 10: + self._payload_warned = True + self.log("La cámara no ha enviado su configuración completa (Windows reutilizó un enlace " + "Bluetooth ya abierto). Los controles funcionan igual y los valores se irán " + "rellenando según los toques. Para sincronizar todo: cierra la ventana de " + "Configuración > Bluetooth de Windows (o apaga y enciende el Bluetooth de la " + "cámara) y vuelve a conectar.") + try: + while True: + d = self.web_cmds.get_nowait() + try: + self._web_cmd(d) + except Exception as e: # noqa: BLE001 + self.log(f"Panel OBS, orden {d.get('cmd')}: {e}") + except queue.Empty: + pass + if self.dock_running: + try: + self.dock.publish(self._web_state()) + except Exception as e: # noqa: BLE001 + self.log(f"Panel OBS, estado: {e}") + self._poll_id = self.after(50, self._poll_events) + + # ------------------------------------------------------------ panel para OBS + def _cell_text(self, cell): + t = cell.val.cget("text") + return "" if t == "—" else t + + def _web_state(self): + angle = speed = None + try: + angle = float(self.angle_var.get()) if self.angle_var.get() else None + except ValueError: + pass + if self.speed_var.get().isdigit(): + speed = int(self.speed_var.get()) + return { + "connected": self.connected, + "busy": self.led.itemcget(self.led_item, "fill") == LED_BUSY, + "name": self.name_var.get(), + "status": self.status_var.get(), + "power": self.power_var.get() if self.power_var.get() != "—" else "", + "tc": self.tc_var.get(), + "rec": self.recording, + "fps": self._cell_text(self.cell_fps), + "shutter": self._cell_text(self.cell_shutter), + "angle": angle, "speed": speed, + "iris": self._cell_text(self.cell_iris), + "iso": self.iso_var.get(), + "wb": self.wb_kelvin, + "tint": self.wb_tint if self.wb_kelvin else None, + "focus": self.focus_value, + "focus_text": self.focus_lbl.cget("text"), + "aperture": self.aperture_value, + "peak": bool(self.peak_var.get()), "peak_colour": self.peak_colour, + "zebra": bool(self.zebra_var.get()), "false": bool(self.false_var.get()), + "af_auto": bool(self.af_auto_var.get()), + "rec_info": self.rec_info_var.get(), + "lens": self.lens_var.get().replace("Objetivo: ", ""), + "notice": self._notice, + } + + def _web_cmd(self, d): + c = d.get("cmd") + if c == "show": + self.deiconify() + self.lift() + self.focus_force() + return + if c == "connect": + self.toggle_connect() + return + if not self.connected: + return + if c == "af": + self.autofocus() + elif c == "focus": + v = max(0.0, min(1.0, float(d.get("v", 0)))) + self.focus_slider.set(v) + self._on_slider("focus", v) + elif c == "focus_delta": + self.nudge_focus(float(d.get("d", 0))) + elif c == "iris": + v = max(0.0, min(1.0, float(d.get("v", 0)))) + self.aperture_slider.set(v) + self._on_slider("aperture", v) + elif c == "iris_delta": + self.nudge_aperture(float(d.get("d", 0))) + elif c == "iris_f": + self._send(self.cam.set_aperture_fstop(float(d["f"])), "Apertura") + elif c == "auto_iris": + self._send(self.cam.auto_iris(), "Auto iris") + elif c == "iso": + self._send(self.cam.set_iso(int(d["v"])), "ISO") + elif c == "angle": + self._send(self.cam.set_shutter_angle(float(d["a"])), "Obturador") + elif c == "speed": + self._send(self.cam.set_shutter_speed(int(d["s"])), "Obturador") + elif c == "wb": + self._set_wb(int(d.get("k", 5600)), int(d.get("t", 0))) + elif c == "awb": + self._send(self.cam.auto_white_balance(), "Auto WB") + elif c == "peak": + self.peak_var.set(bool(d.get("on"))) + self.apply_peaking() + elif c == "peak_colour": + self._set_peak_colour(int(d.get("c", 0))) + elif c == "zebra": + self.zebra_var.set(bool(d.get("on"))) + self.apply_tools() + elif c == "false": + self.false_var.set(bool(d.get("on"))) + self.apply_tools() + elif c == "af_auto": + self.af_auto_var.set(bool(d.get("on"))) + elif c == "record": + self.toggle_record() + else: + self.log(f"Panel OBS: orden desconocida {c}") + + def toggle_dock(self): + on = bool(self.obs_dock_var.get()) + self.cfg["obs_dock"] = on + save_config(self.cfg) + if on and not self.dock_running: + self.dock_running = self.dock.start() + if not self.dock_running: + self.notify(self.dock.error or "No se pudo abrir el panel para OBS", "warn", 8) + self.obs_dock_var.set(False) + elif not on and self.dock_running: + self.dock.stop() + self.dock_running = False + self._update_dock_line() + + def _update_dock_line(self): + self.dock_url_var.set(self.dock.url if self.dock_running else "panel apagado") + + def copy_dock_url(self): + self.clipboard_clear() + self.clipboard_append(self.dock.url) + self.notify("URL copiada: en OBS, Docks → Custom Browser Docks → pégala con el nombre Labmagic", "ok", 6) + + def add_dock_to_obs(self): + ok, msg = obs_add_browser_dock("Labmagic", self.dock.url) + self.notify(msg, "ok" if ok else "warn", 10) + self.log(("Panel OBS: " if ok else "Panel OBS, no añadido: ") + msg) + + def _handle_event(self, ev): + kind = ev["kind"] + if kind == "log": + self.log(ev["text"]) + elif kind == "error": + self.log(f"ERROR {ev['text']}") + self.notify(ev["text"], "error", 10 if ev.get("popup") else 6) + if not self.connected: + self.btn_connect.configure(state="normal") + self.btn_scan.configure(state="normal") + self.btn_pair.configure(state="normal") + self._set_led(LED_OFF) + if "emparej" in ev["text"].lower(): + self.status_var.set("Hace falta emparejar: pulsa «Emparejar (PIN)»") + else: + self.status_var.set("Desconectada") + elif kind == "devices": + self.devices = ev["devices"] + labels = [] + for d in self.devices: + extra = [] + if d.get("paired"): + extra.append("emparejada") + extra.append(f"{d['rssi']} dBm" if d.get("rssi") is not None else "no se anuncia ahora") + labels.append(f"{d['name']} · {d['address']} ({', '.join(extra)})") + self.device_combo.configure(values=labels) + self.btn_scan.configure(state="normal") + if labels: + sel = 0 + last = (self.cfg.get("last_address") or "").upper() + for i, d in enumerate(self.devices): + if d["address"].upper() == last: + sel = i + self.device_combo.current(sel) + # Conexión automática al abrir (solo la primera búsqueda y si la cámara de siempre está) + if (not self._autoconnected and self.autoconnect_var.get() and not self.connected + and self.devices[sel]["address"].upper() == last): + self._autoconnected = True + self.after(200, self.toggle_connect) + else: + self.device_combo.set("No se ha encontrado ninguna cámara") + elif kind == "connecting": + self._set_led(LED_BUSY) + self.status_var.set(ev.get("text") or f"Conectando con {ev['address']}...") + elif kind == "ask_pin": + self._show_pin_dialog(ev["reply"]) + elif kind == "pairing": + stage = ev.get("stage") + self._set_led(LED_BUSY) + if stage == "start": + self.status_var.set("Emparejando: preparando...") + elif stage == "requested": + self.status_var.set("Emparejando: mira el PIN en la pantalla de la cámara") + elif stage == "done": + self.status_var.set("Emparejada. Conectando...") + elif stage == "failed": + self._set_led(LED_OFF) + self.status_var.set("No se pudo emparejar") + self.btn_pair.configure(state="normal") + self.btn_connect.configure(state="normal") + elif kind == "needs_pairing": + self.status_var.set("Hace falta emparejar: pulsa «Emparejar (PIN)»") + self.btn_pair.configure(state="normal") + elif kind == "connected": + self.connected = True + self._connect_time = time.time() + self._payload_count = 0 + self._payload_warned = False + self._set_led(LED_ON) + self.name_var.set(ev.get("name") or "Cámara") + self.btn_connect.configure(text="Desconectar", state="normal") + self.btn_pair.configure(state="normal") + self.btn_scan.configure(state="disabled") + self._set_controls_enabled(True) + self._update_status_line(ev.get("protocol")) + if ev.get("payload") is False: + self._payload_warned = True # el módulo ya lo ha explicado en el registro + self.notify("La cámara no envió su configuración: vuelve a conectar", "warn", 8) + if self._link_held: + self._link_held = False + self.after(200, self._offer_close_settings) + elif kind == "link_held": + self._link_held = True + elif kind == "disconnected": + self.connected = False + self.recording = False + self._set_led(LED_OFF) + self.btn_connect.configure(text="Conectar", state="normal") + self.btn_scan.configure(state="normal") + self.btn_pair.configure(state="normal") + self._set_controls_enabled(False) + self.status_var.set("Desconectada" + (" · la cámara cortó la conexión" if ev.get("reason") == "remote" else "")) + self.tc_var.set("00:00:00:00") + self.tc_label.configure(fg=FG) + self.power_var.set("—") + self._update_record_ui() + elif kind == "status": + self._update_status_line() + elif kind == "timecode": + self.tc_var.set(ev["timecode"]) + elif kind == "sent": + name = PARAM_NAMES.get((ev["cat"], ev["par"]), f"{ev['cat']}.{ev['par']}") + if ev["raw"]: + vals = decode_data(ev["dtype"], ev["raw"]) + shown = ", ".join(f"{v:.3f}" if isinstance(v, float) else str(v) for v in vals) + else: + shown = "(orden)" + op = " (relativo)" if ev.get("op") == 1 else "" + self.log(f"-> {name}{op}: {shown}") + elif kind == "param": + self._payload_count += 1 + self._on_param(ev["cat"], ev["par"], ev["values"], ev["raw"]) + + def _update_status_line(self, protocol=None): + if not self.connected: + return + parts = [] + if self.cam.power_on is not None: + parts.append("encendida" if self.cam.power_on else "apagada") + proto = protocol if protocol is not None else self.cam.protocol_version + if proto: + parts.append(f"protocolo {proto}") + self.status_var.set("Conectada · " + " · ".join(parts) if parts else "Conectada") + pw = self.cam.state.get((9, 0)) + if pw and len(pw[2]) >= 2: + self.power_var.set(f"{pw[2][0] / 1000:.1f} V · {pw[2][1]} %") + + def _update_lens_line(self): + st = self.cam.state + bits = [] + for key in ((12, 9), (12, 11), (12, 10), (12, 12)): + v = st.get(key) + if v and v[2] and isinstance(v[2][0], str) and v[2][0].strip(): + bits.append(v[2][0].strip()) + z = st.get((0, 7)) + if z and z[2] and not any("mm" in b for b in bits): + bits.append(f"{z[2][0]} mm") + self.lens_var.set(("Objetivo: " + " · ".join(bits)) if bits else "") + + def _update_record_ui(self): + self._draw_rec() + if self.recording: + self.rec_state_var.set("GRABANDO") + self.rec_state_lbl.configure(fg=REC) + self.tc_label.configure(fg=REC) + else: + self.rec_state_var.set("GRABAR") + self.rec_state_lbl.configure(fg=FG) + self.tc_label.configure(fg=FG) + self.rec_dot.configure(fg=HUD) + + def _on_param(self, cat, par, values, raw): + key = (cat, par) + name = PARAM_NAMES.get(key) + if (name and cat != 9) or self.show_all_var.get(): + shown = ", ".join(f"{v:.3f}" if isinstance(v, float) else str(v) for v in values) + self.log(f"<- {name or f'{cat}.{par}'}: {shown}") + + if key == (0, 0) and values: + self.focus_value = values[0] + self.focus_slider.set(values[0]) + self.focus_lbl.configure(text=f"{values[0]:.2f}") + if not self._focus_reported: + self._focus_reported = True + self._update_focus_mode() + elif key == (0, 2) and values: + f = 2 ** (values[0] / 2) + txt = f"f/{f:.1f}" + self.aperture_lbl.configure(text=txt) + if not self._lens_iris_text: + self.cell_iris.set(txt) + elif key == (0, 3) and values: + self.aperture_value = values[0] + self.aperture_slider.set(values[0]) + if self.connected: + for b in self.aperture_nudge_btns: + b.configure(state="normal") + elif key == (0, 7) and values: + self.zoom_lbl.configure(text=f"{values[0]} mm") + self._update_lens_line() + elif key == (0, 8) and values: + self.zoom_value = values[0] + self.zoom_slider.set(values[0]) + elif key == (1, 2) and len(values) >= 2: + self.wb_kelvin, self.wb_tint = values[0], values[1] + self.wb_var.set(values[0]) + self.tint_var.set(values[1]) + self.cell_wb.set(f"{values[0]}K") + self.cell_tint.set(f"{values[1]:+d}" if values[1] else "0") + elif key == (1, 7) and values: + if 0 <= values[0] < len(DR_MODES): + self.dr_var.set(DR_MODES[values[0]]) + elif key == (1, 9) and len(values) >= 5: + fps, sfps, w, h, flags = values[:5] + fps_txt = fps_label(fps, bool(flags & 1)) + extra = [] + if flags & 4 and sfps and sfps != fps: + extra.append(f"sensor {fps_label(sfps, bool(flags & 2))} fps") + if flags & 16: + extra.append("ventana") + self.format_var.set(f"{w}×{h} · {fps_txt} fps" + (f" ({', '.join(extra)})" if extra else "")) + self.cell_fps.set(fps_txt) + self._update_rec_info() + # Reflejar en el panel de formato sin enviar nada + if fps_txt in FPS_CHOICES: + self.fps_var.set(fps_txt) + res = next((r for r in RESOLUTIONS if r[1] == w and r[2] == h), None) + self.res_var.set(res[0] if res else f"{w}×{h}") + if flags & 4 and sfps: + s_txt = fps_label(sfps, bool(flags & 2)) + self.sfps_var.set(s_txt if s_txt in SENSOR_FPS_CHOICES else "igual") + else: + self.sfps_var.set("igual") + self.window_var.set(bool(flags & 16)) + elif key == (1, 10) and values: + if 0 <= values[0] < len(AE_MODES): + self.ae_var.set(AE_MODES[values[0]]) + elif key == (1, 11) and values: + self.angle_var.set(f"{values[0] / 100:g}") + self.cell_shutter.set(f"{values[0] / 100:g}°") + elif key == (1, 12) and values: + self.speed_var.set(str(values[0])) + self.cell_shutter.set(f"1/{values[0]}") + elif key == (1, 14) and values: + self.iso_var.set(str(values[0])) + self.cell_iso.set(str(values[0])) + elif key == (1, 15) and len(values) >= 2: + if 0 <= values[0] < len(LUTS): + self.lut_var.set(LUTS[values[0]]) + self.lut_on_var.set(bool(values[1])) + elif key == (4, 1) and values: + bits = values[0] + self.zebra_var.set(bool(bits & 1)) + self.peak_var.set(bool(bits & 2)) + self.false_var.set(bool(bits & 4)) + elif key == (4, 5) and len(values) >= 2: + if 0 <= values[1] <= 4: + self.peak_colour = values[1] + self.peak_colour_btn.configure(text=self._peak_colour_text()) + elif key == (9, 0): + self._update_status_line() + elif key == (10, 0) and len(values) >= 2: + codec = CODECS.get(values[0], f"códec {values[0]}") + variant = CODEC_VARIANTS.get(values[0], {}).get(values[1], f"variante {values[1]}") + self.codec_var.set(f"{codec} {variant}") + self._update_rec_info() + choice = next((c for c in CODEC_CHOICES if c[1] == values[0] and c[2] == values[1]), None) + if choice: + self.codec_sel_var.set(choice[0]) + elif key == (3, 0) and values: + bits = values[0] + self.status_ov_var.set(bool(bits & 1)) + self.guides_ov_var.set(bool(bits & 2)) + self.clean_ov_var.set(bool(bits & 4)) + elif key == (10, 1) and values: + mode = values[0] + self.recording = mode == 2 + self._update_record_ui() + txt = {0: "en espera (preview)", 1: "reproduciendo", 2: "grabando"}.get(mode, f"modo {mode}") + self.transport_var.set(f"Transporte: {txt}") + elif key == (12, 10) and values and isinstance(values[0], str) and values[0].strip(): + self._lens_iris_text = values[0].strip() + self.cell_iris.set(self._lens_iris_text.replace("f", "f/", 1) if self._lens_iris_text.startswith("f") + and not self._lens_iris_text.startswith("f/") else self._lens_iris_text) + self._update_lens_line() + elif cat == 12 and par in (9, 11, 12): + if par == 9 and values and isinstance(values[0], str): + self._lens_type = values[0].strip() + self._update_focus_mode() + if par == 12 and values and isinstance(values[0], str) and values[0].strip(): + # Distancia de enfoque que informa el objetivo: la mostramos junto al deslizador + dist = values[0].strip() + pos = f"{self.focus_value:.2f} · " if self.focus_value is not None else "" + self.focus_lbl.configure(text=f"{pos}{dist}", width=14) + self._update_lens_line() + # Audio, guías, pantalla, tally, color, reproducción y pizarra + try: + self._sync_extra(cat, par, values) + except Exception as e: # noqa: BLE001 + self.log(f"Sincronía {cat}.{par}: {e}") + + # ------------------------------------------------------------- cierre + def on_close(self): + try: + self.after_cancel(self._poll_id) + except Exception: # noqa: BLE001 + pass + try: + self.cfg["pos"] = [self.winfo_x(), self.winfo_y()] + save_config(self.cfg) + except Exception: # noqa: BLE001 + pass + if self.connected: + try: + self.worker.submit(self.cam.disconnect()).result(timeout=3) + except Exception: # noqa: BLE001 + pass + self.worker.stop() + try: + self.dock.stop() + except Exception: # noqa: BLE001 + pass + self.destroy() + + +if __name__ == "__main__": + if not single_instance_or_focus(): + r = tk.Tk() + r.withdraw() + messagebox.showinfo("Labmagic", "BMPCC Control ya está abierta.") + sys.exit(0) + try: + open(LOG_PATH, "w", encoding="utf-8").close() + except OSError: + pass + app = App() + app.mainloop() diff --git a/bmpcc_ble.py b/bmpcc_ble.py new file mode 100644 index 0000000..851a5b3 --- /dev/null +++ b/bmpcc_ble.py @@ -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(" 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_ 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("= 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("= 5 else bytearray([0, 0, 0, 0, 0]) + data[0] = mode & 0xFF + data[1] = struct.pack(" + + + + +Labmagic + + + + +
+
+ + Sin cámara + 00:00:00:00● +
+
Buscando Labmagic…
+
+
FPS
—
+
SHUTTER
—
+
IRIS
—
+
ISO
—
+
WB
—
+
TINT
—
+
+
+ +
+
+ + +
+
+ + + +
+
+
Foco—
+ + + +
+
+
Iris—
+ + + +
+
+ +
GRABAR
—
+
+ +
+
+
+ + + + diff --git a/icono.ico b/icono.ico new file mode 100644 index 0000000..b1a3f3e Binary files /dev/null and b/icono.ico differ diff --git a/icono.png b/icono.png new file mode 100644 index 0000000..320753e Binary files /dev/null and b/icono.png differ diff --git a/iconos/opcion-1.ico b/iconos/opcion-1.ico new file mode 100644 index 0000000..231067f Binary files /dev/null and b/iconos/opcion-1.ico differ diff --git a/iconos/opcion-1.png b/iconos/opcion-1.png new file mode 100644 index 0000000..8e28701 Binary files /dev/null and b/iconos/opcion-1.png differ diff --git a/iconos/opcion-2.ico b/iconos/opcion-2.ico new file mode 100644 index 0000000..b1a3f3e Binary files /dev/null and b/iconos/opcion-2.ico differ diff --git a/iconos/opcion-2.png b/iconos/opcion-2.png new file mode 100644 index 0000000..320753e Binary files /dev/null and b/iconos/opcion-2.png differ diff --git a/iconos/opcion-3.ico b/iconos/opcion-3.ico new file mode 100644 index 0000000..16e5cff Binary files /dev/null and b/iconos/opcion-3.ico differ diff --git a/iconos/opcion-3.png b/iconos/opcion-3.png new file mode 100644 index 0000000..27a16c5 Binary files /dev/null and b/iconos/opcion-3.png differ diff --git a/iconos/opcion-4.ico b/iconos/opcion-4.ico new file mode 100644 index 0000000..2138423 Binary files /dev/null and b/iconos/opcion-4.ico differ diff --git a/iconos/opcion-4.png b/iconos/opcion-4.png new file mode 100644 index 0000000..9c2900e Binary files /dev/null and b/iconos/opcion-4.png differ diff --git a/iconos/opciones.png b/iconos/opciones.png new file mode 100644 index 0000000..fe30e93 Binary files /dev/null and b/iconos/opciones.png differ diff --git a/make_icon.py b/make_icon.py new file mode 100644 index 0000000..4e9c613 --- /dev/null +++ b/make_icon.py @@ -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")) diff --git a/obs_dock.py b/obs_dock.py new file mode 100644 index 0000000..db9c571 --- /dev/null +++ b/obs_dock.py @@ -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)." diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..034722d --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +bleak>=3.0