From e6e12f6c2483d6f91a2940710df5e95419d0236f Mon Sep 17 00:00:00 2001 From: Labstream Date: Sat, 10 Oct 2026 11:35:17 -0500 Subject: [PATCH] =?UTF-8?q?Quita=20las=20referencias=20al=20zoom=20de=20la?= =?UTF-8?q?=20interfaz=20antigua=20y=20aclara=20el=20error=20cuando=20la?= =?UTF-8?q?=20c=C3=A1mara=20no=20responde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- app.py | 4912 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 2455 insertions(+), 2457 deletions(-) diff --git a/app.py b/app.py index a1c25a3..bdf3b56 100644 --- a/app.py +++ b/app.py @@ -1,2457 +1,2455 @@ -""" -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() +""" +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._update_lens_line() + elif key == (0, 8) and values: + self.zoom_value = 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()