2466 líneas
121 KiB
Python
2466 líneas
121 KiB
Python
"""
|
||
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("<Configure>", lambda e: self._draw())
|
||
self.bind("<Button-1>", self._press)
|
||
self.bind("<B1-Motion>", self._motion)
|
||
self.bind("<ButtonRelease-1>", 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("<Enter>", lambda e: self._hover(True))
|
||
w.bind("<Leave>", lambda e: self._hover(False))
|
||
w.bind("<Button-1>", 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("<Enter>", self._schedule, add="+")
|
||
widget.bind("<Leave>", self._hide, add="+")
|
||
widget.bind("<ButtonPress>", 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("<Escape>", 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("<Button-1>", 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("<Button-1>", lambda e: cmd())
|
||
if not on:
|
||
lbl.bind("<Enter>", lambda e: lbl.configure(bg="#3c3c3c"))
|
||
lbl.bind("<Leave>", 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._denied_retries = 0
|
||
|
||
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("<space>", self._space_af)
|
||
self.bind_all("<Left>", lambda e: self._arrow_focus(e, -0.01))
|
||
self.bind_all("<Right>", lambda e: self._arrow_focus(e, 0.01))
|
||
self.bind_all("<Shift-Left>", lambda e: self._arrow_focus(e, -0.05))
|
||
self.bind_all("<Shift-Right>", 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("<Button-1>", 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("<Button-1>", lambda e: self.toggle_record())
|
||
self.rec_canvas.bind("<Enter>", lambda e: self._draw_rec(hover=True))
|
||
self.rec_canvas.bind("<Leave>", 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("<Button-1>", 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("<<ComboboxSelected>>",
|
||
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("<<ComboboxSelected>>",
|
||
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("<<ComboboxSelected>>", 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("<Return>", 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("<Return>", 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("<Return>", lambda ev: cmd())
|
||
e.bind("<FocusOut>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<Return>", 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)
|
||
# Al abrir justo después de cerrar otra copia, Windows aún retiene el enlace unos segundos
|
||
# y la cámara responde «acceso denegado»: reintentamos solos dos veces antes de rendirnos.
|
||
if (not self.connected and "Acceso denegado" in ev["text"] and self._denied_retries < 2
|
||
and ev["text"].startswith("Conexión")):
|
||
self._denied_retries += 1
|
||
self.status_var.set(f"Enlace ocupado: reintento {self._denied_retries} de 2 en 10 s...")
|
||
self.after(10000, self.toggle_connect)
|
||
return
|
||
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._denied_retries = 0
|
||
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()
|