""" 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()