# Cómo hacer la librería de una luminaria para DMX Total-Pro

Esta guía sirve para que **Claude** (o cualquier persona con paciencia) arme
la librería de una luminaria que no está en la biblioteca de DMX Total-Pro,
a partir de su manual. Trae todo lo necesario: las reglas, un ejemplo
completo y el programa que escribe el archivo.

## Si eres la persona que la necesita

1. Ten el **manual** de tu luminaria (el PDF, o una foto clara de su
   «carta DMX») y decide en qué **modo de canales** la vas a usar: el mismo
   que pongas en el menú del aparato (por ejemplo «Standard 24CH»).
2. Abre [claude.ai](https://claude.ai), **adjunta este archivo y el manual**,
   y escribe algo como:
   > Con esta guía, hazme la librería de DMX Total-Pro de esta luminaria en
   > el modo de 24 canales.
3. Claude te dará un archivo **`.pff`**. En DMX Total-Pro: **Biblioteca ›
   Importar…** y elígelo. Ábrelo con **Editar…** para revisarlo y pruébalo
   con la luminaria antes de un show.

Lo que el manual no diga (el dibujo de cada gobo, los colores de un aro LED)
se completa después en el editor, moviendo la luminaria con su fader.

---

## Si eres Claude: cómo armarla

Vas a escribir la descripción del aparato en **JSON**, correr el programa del
final de esta guía y entregar el `.pff` que produce. Necesitas poder ejecutar
Python 3 (solo la biblioteca estándar). No escribas el `.fxt` a mano: el
formato es de posiciones fijas, codificación Windows-1252, y un renglón de
más corre todo lo que sigue.

### 1. La carta DMX

- Busca en el manual la tabla del **modo que pidió la persona**. Si el manual
  no trae ese modo, dilo antes de seguir; no inventes el orden de los canales.
- Un objeto en `"canales"` por cada canal, **en orden**. El primero es el
  canal 1 del aparato.
- Lee los tramos de cada canal (`000-003 cerrado`, `004-103 strobe…`): de ahí
  salen los valores. Cuando elijas un valor dentro de un tramo, toma uno de
  en medio o dos arriba del inicio, nunca la orilla: el aparato de al lado
  del estante puede estar calibrado un par de pasos distinto.

### 2. Qué es cada canal

Cada canal lleva `"nombre"` (lo que dirá la consola, en español) y, si hace
una de estas funciones, `"funcion"`. Cada función va **en un solo canal**.
Si un canal hace dos cosas (Shutter/Strobe, Lamp/Reset, Dimmer/Lamp), la
segunda va en `"tambien"`.

| Lo que dice el manual | `funcion` |
|---|---|
| Pan, X, horizontal | `pan` |
| Pan fine, X fine, pan 16 bit | `pan_fino` |
| Tilt, Y, vertical | `tilt` |
| Tilt fine, Y fine | `tilt_fino` |
| Dimmer, intensity, master | `dimmer` |
| Shutter (abierto/cerrado) | `obturador` |
| Strobe (en el mismo canal que el shutter, va en `tambien`) | `strobe` |
| Red / Green / Blue | `rojo` / `verde` / `azul` |
| Amber o White | `ambar` |
| Color wheel (1 y 2) | `color1`, `color2` |
| Gobo wheel (1, 2, 3) | `gobo1`, `gobo2`, `gobo3` |
| Gobo rotation / index (de la rueda 1 y 2) | `gobo_rot1`, `gobo_rot2` |
| Iris / Focus / Zoom / Frost | `iris` / `foco` / `zoom` / `frost` |
| Prism | `prisma` |
| Prism rotation | `prisma_rot` |
| Lamp on/off | `lampara` |
| Reset | `reset` |
| Fog, haze, smoke / Fan | `niebla` / `ventilador` |

Hay canales que DMX Total-Pro reconoce **por su nombre exacto** y no por una
función: se escriben así en `"nombre"`, sin `"funcion"`.

| `nombre` | Para qué |
|---|---|
| `Arcoíris` | Un canal de efecto arcoíris propio (Rainbow) |
| `Velocidad` | La velocidad del efecto del canal de al lado: programa, escenas, arcoíris. **No** la de los motores |
| `Efectos` | Un canal de efectos del aparato |
| `Modo automático` | Auto, programas internos, sound active |
| `Macros` | Canal de programas; cada programa va en `"macros"` |
| `UV` | El color ultravioleta de un par RGBWAUV |
| `Prisma 2`, `Prisma 3` | El segundo y tercer prisma de un beam que trae varios |
| `Aro de Color` | El canal de colores o macros de un aro LED alrededor del lente |
| `Shake` | Un canal aparte que hace temblar el gobo |

Todo lo demás (la velocidad de pan y tilt, el modo de color, los canales de
un aro LED que no son su selector de colores…) va **solo con un nombre
claro** y sin función: la consola lo enseña como un deslizador con ese
nombre. La velocidad de los motores se llama `Velocidad XY`, nunca
`Velocidad`.

### 3. Con qué arranca: `al_iniciar`

Al abrirse, la consola manda a cada canal su `"al_iniciar"`. Es
**obligatorio** en los canales de dimmer, lámpara y obturador (el programa
no sigue sin él), porque si falta la consola les manda 255, y en muchos
aparatos **255 en el canal de la lámpara es «Reset»**: reiniciaría la
luminaria cada vez que se abre la consola.

- Dimmer: `255`.
- Obturador: el valor de **abierto** según la carta.
- Lámpara: el valor que la **enciende** («Lamp on»). Jamás el de reset.
- Los demás canales: no lo pongas.

### 4. Los valores DMX (`"valores"`)

Todos opcionales, y todos salen de la carta. Solo pon los que el aparato
tiene.

| Clave | Qué valor |
|---|---|
| `obturador_abierto`, `obturador_cerrado` | Shutter open / closed |
| `strobe_lento`, `strobe_medio`, `strobe_rapido` | Dentro del tramo de strobe |
| `lampara_encender`, `lampara_apagar` | Lamp on / Lamp off |
| `reset_valor` | Reset |
| `prisma_puesto` | El que mete el prisma |
| `prisma_rot_lento`, `prisma_rot_medio`, `prisma_rot_rapido` | Giro del prisma, por su NOMBRE: si la carta dice «rápido a lento», el rápido es el valor bajo |
| `iris_abierto`, `iris_cerrado` | Solo si no son 255 abierto y 0 cerrado |

### 5. Las ruedas (`"ruedas"`)

Cada rueda es una lista de `[valor, "lo que es"]`, **de menor a mayor**. El
valor es el que la consola manda para ponerla ahí.

- **Colores** (`color1`, `color2`): `"#rrggbb"` un color; `"#rrggbb#rrggbb"`
  medio color (dos partidos); `"#auto-lento"`, `"#auto-medio"`,
  `"#auto-rapido"` la rueda girando sola. Usa estos colores, que la consola
  nombra: `#ffffff` Blanco, `#ff0000` Rojo, `#ff6a00` Naranja, `#ffb000` Ámbar, `#ffee00` Amarillo, `#00c02a` Verde, `#7dff5a` Verde claro, `#00d5d5` Cian, `#0038ff` Azul, `#4fa8ff` Azul claro, `#1a00b0` Congo, `#e400c8` Magenta, `#ff86c8` Rosa, `#b48cff` Lavanda, `#5a00c8` UV. Un color que no esté ahí va al más parecido. Si la
  rueda gira en los dos sentidos, pon los tres automáticos de cada uno. Si
  el manual no dice de qué color es una posición («Color 1», «Color 2»…),
  ponla en `""`: no inventes el color. Se le pone en el editor viendo la
  luz; díselo a la persona.
- **Gobos** (`gobo1`, `gobo2`, `gobo3`): `"#ffffff"` abierto; `""` un gobo
  sin dibujo; `"#gobo-estrella"` si el manual enseña un gobo que se parece a
  uno de estos dibujos: estrella, corazon, media-luna, triangulo, cuadro, flor, circulo, puntos, espiral, rayas, cruz, rombo, hojas, burbujas, rejilla, sol. `"#shake-3"` es el shake del gobo 3 en
  la misma rueda, y `"#auto-…"` la rueda girando. Si tienes la foto de un
  gobo, su nombre de archivo (junto al JSON) también vale. Ante la duda,
  `""`: la persona escoge el dibujo después.

### 6. Lo demás

- `"clase"`: `cabeza` (cabeza móvil, beam, spot, wash con movimiento),
  `par`, `laser` u `otro` (humo, chispero, burbujas…).
- `"arcoiris"`: el efecto Arcoíris de la consola. Solo si el aparato trae un
  canal de arcoíris aparte: `{"12": {"lento": 224}}` (canal: velocidades).
  Si la rueda de color tiene tramos automáticos, la consola ya sabe girarla y
  no hace falta.
- `"macros"`: los programas del canal `Macros`, `[[valor, "nombre"], …]`.
- `"aro_de_color"`: los colores del canal `Aro de Color`,
  `[[valor, "#rrggbb" o "#auto-…"], …]`. Si el manual no los lista, déjalo
  fuera: se meten después con el fader.
- `"imagen"`: el nombre de la foto del aparato, si la tienes junto al JSON.
- `"comentario"`: el modo y de dónde salió (`"Modo Standard 24CH, del
  manual"`).
- Cualquier clave que empiece con `_` es una nota tuya y no se lee.

### 7. Correr el programa y entregar

1. Guarda el JSON (por ejemplo `aparato.json`) y el programa
   `crear_libreria_dmx.py` (está completo al final) en la misma carpeta.
2. `python crear_libreria_dmx.py aparato.json`
3. Si dice «Hay N cosa(s) que arreglar», arréglalas y vuelve a correrlo. Lee
   también los «Aviso:».
4. Entrega el **`.pff`** y un resumen corto: el modo, los canales que no
   supiste qué eran, lo que el manual no trae (gobos sin dibujo, colores del
   aro) y el `al_iniciar` de la lámpara, dicho claramente.

---

## Ejemplo: Lite Tek BEASTY, modo Standard de 24 canales

<!-- ejemplo: inicio -->
```json
{
  "_fuente": "Manual de Lite Tek, carta DMX del modo Standard 24CH",
  "fabricante": "Lite Tek",
  "nombre": "BEASTY",
  "clase": "cabeza",
  "comentario": "Modo Standard de 24 canales (menu Channel > Standard 24CH). Hecho del manual de Lite Tek.",
  "imagen": "BEASTY.png",
  "canales": [
    {"nombre": "Pan", "funcion": "pan"},
    {"nombre": "Tilt", "funcion": "tilt"},
    {"nombre": "Pan fino", "funcion": "pan_fino"},
    {"nombre": "Tilt fino", "funcion": "tilt_fino"},
    {"nombre": "Velocidad XY", "_nota": "velocidad de los motores: sin función"},
    {"nombre": "Obturador / Strobe", "funcion": "obturador", "tambien": "strobe", "al_iniciar": 255},
    {"nombre": "Dimmer", "funcion": "dimmer", "al_iniciar": 255},
    {"nombre": "Rueda de color 1", "funcion": "color1"},
    {"nombre": "Medio color", "_nota": "000-127 color completo, 128-255 medio color"},
    {"nombre": "Rueda de gobos 1", "funcion": "gobo1"},
    {"nombre": "Shake"},
    {"nombre": "Frost / Rainbow", "funcion": "frost", "_nota": "128-191 frost, 192-255 rainbow"},
    {"nombre": "Foco", "funcion": "foco"},
    {"nombre": "Prisma", "funcion": "prisma"},
    {"nombre": "Rotación de prisma", "funcion": "prisma_rot"},
    {"nombre": "Lámpara / Reset", "funcion": "lampara", "tambien": "reset", "al_iniciar": 202,
     "_nota": "200-205 enciende; 250-255 es Reset All: NUNCA arrancar en 255"},
    {"nombre": "Aro Dimmer"},
    {"nombre": "Aro Strobe"},
    {"nombre": "Aro Rojo"},
    {"nombre": "Aro Verde"},
    {"nombre": "Aro Azul"},
    {"nombre": "Aro de Color", "_nota": "el manual no trae sus colores: se meten con el fader"},
    {"nombre": "Aro Escenas"},
    {"nombre": "Velocidad", "_nota": "la de las escenas del aro, el canal de al lado"}
  ],
  "ruedas": {
    "color1": [
      [0, "#ffffff"], [7, "#ffffff#ff0000"], [12, "#ff0000"], [17, "#ff0000#ffee00"],
      [22, "#ffee00"], [27, "#ffee00#0038ff"], [32, "#0038ff"], [37, "#0038ff#00c02a"],
      [42, "#00c02a"], [47, "#00c02a#ff6a00"], [52, "#ff6a00"], [57, "#ff6a00#ffee00"],
      [62, "#ffee00"], [67, "#ffee00#7dff5a"], [72, "#7dff5a"], [77, "#7dff5a#1a00b0"],
      [82, "#1a00b0"], [87, "#1a00b0#4fa8ff"], [92, "#4fa8ff"], [97, "#4fa8ff#ff86c8"],
      [102, "#ff86c8"], [107, "#ff86c8#00d5d5"], [112, "#00d5d5"], [117, "#00d5d5#ffb000"],
      [122, "#ffb000"], [127, "#ffb000#e400c8"], [132, "#e400c8"], [137, "#e400c8#ffffff"],
      [140, "#auto-rapido"], [170, "#auto-medio"], [198, "#auto-lento"],
      [203, "#auto-lento"], [228, "#auto-medio"], [253, "#auto-rapido"]
    ],
    "gobo1": [
      [0, "#ffffff"], [10, ""], [17, ""], [24, ""], [31, ""], [38, ""], [45, ""],
      [52, ""], [59, ""], [66, ""], [73, ""], [80, ""], [87, ""], [94, ""],
      [98, "#auto-rapido"], [137, "#auto-medio"], [174, "#auto-lento"],
      [179, "#auto-lento"], [216, "#auto-medio"], [253, "#auto-rapido"]
    ]
  },
  "valores": {
    "obturador_abierto": 255, "obturador_cerrado": 0,
    "strobe_lento": 10, "strobe_medio": 55, "strobe_rapido": 100,
    "lampara_encender": 202, "lampara_apagar": 102, "reset_valor": 252,
    "prisma_puesto": 128,
    "prisma_rot_lento": 189, "prisma_rot_medio": 160, "prisma_rot_rapido": 130
  },
  "arcoiris": {"12": {"lento": 224}}
}
```
<!-- ejemplo: fin -->

## El programa: `crear_libreria_dmx.py`

<!-- programa: inicio -->
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Arma la librería de una luminaria para DMX Total-Pro.

    python crear_libreria_dmx.py aparato.json [carpeta_de_salida]

Lee la descripción del aparato (un JSON: la guía dice cómo se llena) y
escribe dos archivos, llamados «Fabricante_Nombre»:

    .fxt   el perfil, en el formato de FreeStyler con los agregados de
           DMX Total-Pro. Es texto: se puede abrir y revisar.
    .pff   el mismo perfil con sus imágenes dentro. Es el que se importa en
           DMX Total-Pro con Biblioteca > Importar...

Solo usa la biblioteca estándar de Python 3. Las imágenes que cite el JSON
(la foto del aparato, fotos de gobos) se buscan junto al JSON.

Si algo del JSON no cuadra, se detiene y dice qué y dónde, todo junto: es
preferible a una librería que abre bien y en la luminaria hace otra cosa.
"""
import json
import os
import re
import sys
import unicodedata

# -- lo que entiende DMX Total-Pro --------------------------------------
# Las funciones de un canal. La clave es lo que va en "funcion"/"tambien".
FUNCIONES = (
    ("pan", "Pan"), ("pan_fino", "Pan fino"),
    ("tilt", "Tilt"), ("tilt_fino", "Tilt fino"),
    ("dimmer", "Dimmer"), ("obturador", "Obturador"), ("strobe", "Strobe"),
    ("rojo", "Rojo"), ("verde", "Verde"), ("azul", "Azul"),
    ("ambar", "Ámbar / blanco"),
    ("color1", "Rueda de color 1"), ("color2", "Rueda de color 2"),
    ("gobo1", "Rueda de gobos 1"), ("gobo_rot1", "Rotación de gobo 1"),
    ("gobo2", "Rueda de gobos 2"), ("gobo_rot2", "Rotación de gobo 2"),
    ("gobo3", "Rueda de gobos 3"),
    ("iris", "Iris"), ("foco", "Foco"), ("zoom", "Zoom"), ("frost", "Frost"),
    ("prisma", "Prisma"), ("prisma_rot", "Rotación de prisma"),
    ("lampara", "Lámpara"), ("reset", "Reset"),
    ("niebla", "Niebla"), ("ventilador", "Ventilador"),
)
CLAVES = tuple(c for c, _ in FUNCIONES)
RUEDAS = ("gobo1", "color1", "gobo2", "color2", "gobo3")
RUEDAS_DE_COLOR = ("color1", "color2")
# Los canales que no son una función del formato sino un NOMBRE exacto: el
# canal que se llame así hace eso en la consola.
NOMBRES_SUELTOS = ("Arcoíris", "Velocidad", "Efectos", "Modo automático",
                   "UV", "Macros", "Prisma 2", "Prisma 3", "Aro de Color",
                   "Shake")
# Las tres que, si el perfil no dice con qué valor arrancan, la consola
# pone en 255 al abrirse. Aquí se exige decirlo: en muchos aparatos 255 en
# el canal de la lámpara es «Reset».
QUE_ABREN = ("dimmer", "lampara", "obturador")
CLASES = ("cabeza", "par", "laser", "otro")
# La paleta: con estos colores la consola dice «Rojo», «Rojo + Azul»...
# Uno fuera de ella sirve igual, pero sale escrito como «#2a5cff».
PALETA = {
    "#ffffff": "Blanco", "#ff0000": "Rojo", "#ff6a00": "Naranja",
    "#ffb000": "Ámbar", "#ffee00": "Amarillo", "#00c02a": "Verde",
    "#7dff5a": "Verde claro", "#00d5d5": "Cian", "#0038ff": "Azul",
    "#4fa8ff": "Azul claro", "#1a00b0": "Congo", "#e400c8": "Magenta",
    "#ff86c8": "Rosa", "#b48cff": "Lavanda", "#5a00c8": "UV",
}
# Los gobos que la consola sabe dibujar («#gobo-estrella»...).
DIBUJOS = ("estrella", "corazon", "media-luna", "triangulo", "cuadro",
           "flor", "circulo", "puntos", "espiral", "rayas", "cruz", "rombo",
           "hojas", "burbujas", "rejilla", "sol")
VELOCIDADES = ("lento", "medio", "rapido")
# Los valores DMX de "valores", con el nombre del atributo del perfil.
VALORES = ("obturador_abierto", "obturador_cerrado", "strobe_lento",
           "strobe_medio", "strobe_rapido", "lampara_encender",
           "lampara_apagar", "reset_valor", "prisma_puesto",
           "prisma_rot_lento", "prisma_rot_medio", "prisma_rot_rapido",
           "iris_abierto", "iris_cerrado")
IRIS_DE_FABRICA = {"iris_abierto": 255, "iris_cerrado": 0}
# El final del .fxt que FreeStyler espera y la consola no usa.
COLA = ["", "", "", "", "", "", "", "255", "", "", "0", "255", "0", "255",
        "0", "255", "0", "255", "0", "255", "0", "255", "0", "255", "0", "0",
        "0", "", "", "-1"]
CODIFICACION = "cp1252"
CANALES_MAXIMO = 512
# Lo que se puede escribir en el JSON. Una clave que empiece por «_» es una
# nota y no se lee; cualquier otra que no esté aquí se toma por un error de
# dedo: «valor» por «valores» tiraría los valores sin decir nada.
CLAVES_DEL_JSON = ("fabricante", "nombre", "clase", "comentario", "imagen",
                   "canales", "ruedas", "valores", "arcoiris", "macros",
                   "aro_de_color")
CLAVES_DEL_CANAL = ("nombre", "funcion", "tambien", "al_iniciar")

_COLOR = re.compile(r"^#[0-9a-f]{6}$")
_PARTIDO = re.compile(r"^#[0-9a-f]{6}#[0-9a-f]{6}$")
_AUTOMATICO = re.compile(r"^#auto-(lento|medio|rapido)$")
_GOBO = re.compile(r"^#gobo-([a-z][a-z0-9-]*)$")
_SHAKE = re.compile(r"^#shake-([1-9][0-9]*)(?:-([a-z][a-z0-9-]*))?$")


class Problemas(ValueError):
    """Lo que no cuadra del JSON, todo junto."""

    def __init__(self, lista):
        self.lista = list(lista)
        super().__init__("Hay %d cosa(s) que arreglar en la descripción:\n"
                         % len(self.lista)
                         + "\n".join("  - " + p for p in self.lista))


class Libreria:
    """Lo que sale: el .fxt, su nombre, las imágenes y los avisos."""

    def __init__(self, nombre_fxt, texto_fxt, imagenes, avisos, resumen):
        self.nombre_fxt = nombre_fxt
        self.texto_fxt = texto_fxt
        self.imagenes = imagenes          # [(nombre, bytes)] que se hallaron
        self.avisos = avisos
        self.resumen = resumen

    @property
    def nombre_pff(self):
        return self.nombre_fxt[:-4] + ".pff"

    def bytes_fxt(self):
        return self.texto_fxt.encode(CODIFICACION, "replace")

    def bytes_pff(self):
        """El contenedor .pff: `nombre\\0tamaño\\0` y los bytes, primero el
        .fxt y luego sus imágenes, como los de la biblioteca oficial."""
        partes = []
        for nombre, datos in [(self.nombre_fxt, self.bytes_fxt())] + \
                list(self.imagenes):
            partes.append(nombre.encode("latin-1", "replace") + b"\x00"
                          + str(len(datos)).encode("ascii") + b"\x00"
                          + bytes(datos))
        return b"".join(partes)


def _plano(texto):
    """Sin acentos, en minúsculas y sin espacios de más: «Arcoiris» y
    «ARCOÍRIS» son el mismo nombre suelto."""
    sin = unicodedata.normalize("NFKD", str(texto or ""))
    sin = "".join(c for c in sin if not unicodedata.combining(c))
    return " ".join(sin.lower().split())


_SUELTO_POR_PLANO = {_plano(n): n for n in NOMBRES_SUELTOS}


def nombre_de_archivo(fabricante, nombre):
    """«Fabricante_Nombre.fxt», sin lo que Windows no admite."""
    crudo = "%s_%s" % (fabricante.strip() or "Perfil",
                       nombre.strip() or "sin nombre")
    limpio = "".join(c if c.isalnum() or c in " _-." else "_" for c in crudo)
    return limpio.strip(" .") + ".fxt"


def _entero(valor, donde, problemas, minimo=0, maximo=255):
    if isinstance(valor, bool) or not isinstance(valor, int):
        problemas.append("%s: «%r» no es un número entero." % (donde, valor))
        return None
    if not minimo <= valor <= maximo:
        problemas.append("%s: %d está fuera de %d-%d."
                         % (donde, valor, minimo, maximo))
        return None
    return valor


def _texto_limpio(texto):
    return " ".join(str(texto or "").replace('"', "'").split())


def armar(descripcion, carpeta_imagenes=None):
    """La `Libreria` de ese aparato, o `Problemas` si algo no cuadra."""
    d = descripcion
    problemas, avisos = [], []
    if not isinstance(d, dict):
        raise Problemas(["La descripción debe ser un objeto JSON {...}."])
    for clave in d:
        if not str(clave).startswith("_") and clave not in CLAVES_DEL_JSON:
            problemas.append("«%s» no se conoce. Las claves son: %s (y las "
                             "que empiezan por «_», que son notas)."
                             % (clave, ", ".join(CLAVES_DEL_JSON)))
    fabricante = _texto_limpio(d.get("fabricante")) or "Personalizado"
    nombre = _texto_limpio(d.get("nombre"))
    if not nombre:
        problemas.append("Falta «nombre» (el modelo del aparato).")
    clase = str(d.get("clase") or "").strip().lower()
    if clase not in CLASES:
        problemas.append("«clase» debe ser una de %s, y es «%s»."
                         % (", ".join(CLASES), clase))
    comentario = _texto_limpio(d.get("comentario"))
    imagen = str(d.get("imagen") or "").strip()

    # -- los canales ----------------------------------------------------
    lista = d.get("canales")
    if not isinstance(lista, list) or not 1 <= len(lista) <= CANALES_MAXIMO:
        raise Problemas(["«canales» debe ser una lista de 1 a %d canales."
                         % CANALES_MAXIMO])
    n_canales = len(lista)
    nombres = []
    canal_de = {c: 0 for c in CLAVES}
    arranque = {}
    for n, canal in enumerate(lista, start=1):
        donde = "Canal %d" % n
        if not isinstance(canal, dict):
            problemas.append("%s: debe ser un objeto con «nombre»." % donde)
            nombres.append("Canal %d" % n)
            continue
        for clave in canal:
            if not str(clave).startswith("_") and \
                    clave not in CLAVES_DEL_CANAL:
                problemas.append("%s: «%s» no se conoce. Un canal lleva: %s."
                                 % (donde, clave, ", ".join(CLAVES_DEL_CANAL)))
        suyo = _texto_limpio(canal.get("nombre")) or "Canal %d" % n
        # Un nombre suelto se escribe EXACTO: la consola compara el nombre.
        suyo = _SUELTO_POR_PLANO.get(_plano(suyo), suyo)
        nombres.append(suyo)
        claves = [canal.get("funcion"), canal.get("tambien")]
        claves = [c for c in claves if c not in (None, "", "-")]
        if len(claves) == 2 and claves[0] == claves[1]:
            problemas.append("%s: «funcion» y «tambien» son la misma." % donde)
            claves = claves[:1]
        for clave in claves:
            if clave not in CLAVES:
                problemas.append(
                    "%s: «%s» no es una función. Las que hay: %s. Si es un "
                    "nombre suelto (%s), va en «nombre» y sin «funcion»."
                    % (donde, clave, ", ".join(CLAVES),
                       ", ".join(NOMBRES_SUELTOS)))
            elif canal_de[clave]:
                problemas.append(
                    "%s: «%s» ya está en el canal %d. Cada función va en un "
                    "solo canal; un segundo canal que haga lo mismo va sin "
                    "«funcion», solo con su nombre." % (donde, clave,
                                                         canal_de[clave]))
            else:
                canal_de[clave] = n
        if "al_iniciar" in canal and canal["al_iniciar"] is not None:
            valor = _entero(canal["al_iniciar"], donde + ", «al_iniciar»",
                            problemas)
            if valor is not None:
                arranque[n] = valor
        elif any(c in QUE_ABREN for c in claves):
            problemas.append(
                "%s tiene %s: di con qué valor arranca en «al_iniciar». Sin "
                "él la consola le manda 255 al abrirse, y en muchos aparatos "
                "255 en el canal de la lámpara es «Reset»: el valor que "
                "enciende o abre, según el manual." % (
                    donde, " y ".join("«%s»" % c for c in claves
                                      if c in QUE_ABREN)))

    # -- los valores DMX -------------------------------------------------
    valores = {v: 0 for v in VALORES}
    valores.update(IRIS_DE_FABRICA)
    for clave, valor in (d.get("valores") or {}).items():
        if clave not in VALORES:
            problemas.append("«valores»: «%s» no se conoce. Los que hay: %s."
                             % (clave, ", ".join(VALORES)))
            continue
        valor = _entero(valor, "«valores», «%s»" % clave, problemas)
        if valor is not None:
            valores[clave] = valor

    # El arranque no puede caer en el reset.
    reset = canal_de["reset"]
    if reset and valores["reset_valor"] and reset in arranque and \
            abs(arranque[reset] - valores["reset_valor"]) <= 5:
        problemas.append(
            "Canal %d: arranca en %d, pegado al reset (%d). Cada vez que se "
            "abriera la consola reiniciaría el aparato."
            % (reset, arranque[reset], valores["reset_valor"]))
    if reset and not valores["reset_valor"]:
        avisos.append("Hay canal de reset pero no «reset_valor»: el botón "
                      "Reset de la consola no sabrá qué mandar.")
    if canal_de["lampara"] and not valores["lampara_encender"]:
        avisos.append("Hay canal de lámpara pero no «lampara_encender».")
    if canal_de["obturador"] and not (valores["obturador_abierto"]
                                      or valores["obturador_cerrado"]):
        avisos.append("Hay obturador pero ni «obturador_abierto» ni "
                      "«obturador_cerrado»: el escenario no sabrá si deja "
                      "pasar la luz.")
    if canal_de["strobe"] and not valores["strobe_rapido"]:
        avisos.append("Hay strobe pero no «strobe_lento/medio/rapido»: los "
                      "presets de strobe no escribirán nada.")
    prisma, giro = canal_de["prisma"], canal_de["prisma_rot"]
    if valores["prisma_puesto"] and not prisma:
        problemas.append("«prisma_puesto» tiene valor pero ningún canal es "
                         "«prisma».")
    canal_del_giro = giro or prisma
    for v in ("prisma_rot_lento", "prisma_rot_medio", "prisma_rot_rapido"):
        if valores[v] and not canal_del_giro:
            problemas.append("«%s» tiene valor pero no hay canal de prisma "
                             "ni de su rotación." % v)
    for v in IRIS_DE_FABRICA:
        if valores[v] != IRIS_DE_FABRICA[v] and not canal_de["iris"]:
            problemas.append("«%s» tiene valor pero ningún canal es «iris»."
                             % v)

    # -- las ruedas ----------------------------------------------------
    ruedas = {c: [] for c in RUEDAS}
    sin_color = {}
    for clave, entradas in (d.get("ruedas") or {}).items():
        if clave not in RUEDAS:
            problemas.append("«ruedas»: «%s» no es una rueda. Las que hay: %s."
                             % (clave, ", ".join(RUEDAS)))
            continue
        if not canal_de[clave]:
            problemas.append("La rueda «%s» tiene posiciones pero ningún "
                             "canal tiene la función «%s»." % (clave, clave))
        anterior = -1
        gobos = 0
        for i, par in enumerate(entradas or [], start=1):
            donde = "Rueda «%s», posición %d" % (clave, i)
            if not isinstance(par, (list, tuple)) or len(par) != 2:
                problemas.append("%s: debe ser [valor, \"lo que es\"]."
                                 % donde)
                continue
            valor = _entero(par[0], donde, problemas)
            que = str(par[1] or "").strip()
            if que.startswith("#"):
                que = que.lower()
            if valor is None:
                continue
            if valor <= anterior:
                problemas.append("%s: los valores van de menor a mayor, y %d "
                                 "no es mayor que %d." % (donde, valor,
                                                          anterior))
            anterior = valor
            problema = _posicion_rara(clave, que, gobos)
            if problema:
                problemas.append("%s: %s" % (donde, problema))
            if clave not in RUEDAS_DE_COLOR and (
                    not que or _GOBO.match(que)
                    or (que and not que.startswith("#"))):
                gobos += 1
            for color in _colores_de(que):
                if color not in PALETA:
                    avisos.append("%s: %s no es de la paleta; la consola lo "
                                  "enseñará como código." % (donde, color))
            if clave in RUEDAS_DE_COLOR and not que:
                sin_color[clave] = sin_color.get(clave, 0) + 1
            ruedas[clave].append((valor, que))
    for clave in RUEDAS:
        if canal_de[clave] and not ruedas[clave]:
            avisos.append("El canal %d es «%s» pero la rueda no tiene "
                          "posiciones." % (canal_de[clave], clave))
    for clave, cuantas in sin_color.items():
        avisos.append("La rueda «%s» tiene %d posición(es) sin color: la "
                      "consola no las podrá elegir por su color hasta que se "
                      "lo pongan en el editor, viendo la luz." % (clave,
                                                                  cuantas))

    # -- lo que va en macros marcadas --------------------------------------
    llamado = {}
    for n, suyo in enumerate(nombres, start=1):
        llamado.setdefault(_plano(suyo), n)
    macros = []

    def vector(canal, valor):
        return [valor if c == canal else -1 for c in range(1, n_canales + 1)]

    # El arcoíris que gira la cabeza: {canal: {lento/medio/rapido/fijo: v}}.
    anclas, fijos = {}, {}
    for canal, velocidades in (d.get("arcoiris") or {}).items():
        try:
            canal = int(canal)
        except (TypeError, ValueError):
            problemas.append("«arcoiris»: «%s» no es un canal." % canal)
            continue
        if not 1 <= canal <= n_canales:
            problemas.append("«arcoiris»: el canal %d no existe." % canal)
            continue
        for velocidad, valor in (velocidades or {}).items():
            valor = _entero(valor, "«arcoiris», canal %d, «%s»"
                            % (canal, velocidad), problemas)
            if valor is None:
                continue
            if velocidad == "fijo":
                fijos[canal] = valor
            elif velocidad in VELOCIDADES:
                anclas.setdefault(canal, {})[velocidad] = valor
            else:
                problemas.append("«arcoiris», canal %d: «%s» no es lento, "
                                 "medio, rapido ni fijo." % (canal, velocidad))
    usadas = [v for v in VELOCIDADES if any(v in s for s in anclas.values())]
    if fijos and not usadas:
        problemas.append("«arcoiris» solo tiene canales fijos: falta el valor "
                         "con el que gira.")
    for canal, valor in fijos.items():
        if canal in anclas:
            problemas.append("«arcoiris», canal %d: está fijo y también gira."
                             % canal)
        anclas[canal] = {v: valor for v in usadas}
    for velocidad in VELOCIDADES:
        vec = [anclas.get(c, {}).get(velocidad, -1)
               for c in range(1, n_canales + 1)]
        if any(v >= 0 for v in vec):
            macros.append((vec, "#arcoiris-" + velocidad))

    # Los programas del canal «Macros» y los colores del «Aro de Color».
    for campo, suelto, marca in (("macros", "Macros", "#macro:"),
                                 ("aro_de_color", "Aro de Color", "#aro:")):
        filas = d.get(campo) or []
        if not filas:
            continue
        canal = llamado.get(_plano(suelto), 0)
        if not canal:
            problemas.append("«%s» tiene filas pero ningún canal se llama "
                             "«%s»." % (campo, suelto))
            continue
        for i, par in enumerate(filas, start=1):
            donde = "«%s», fila %d" % (campo, i)
            if not isinstance(par, (list, tuple)) or len(par) != 2:
                problemas.append("%s: debe ser [valor, \"...\"]." % donde)
                continue
            valor = _entero(par[0], donde, problemas)
            que = " ".join(str(par[1] or "").split())
            if campo == "aro_de_color":
                que = que.lower()
                if not (_COLOR.match(que) or _PARTIDO.match(que)
                        or _AUTOMATICO.match(que)):
                    problemas.append("%s: «%s» no es un color «#rrggbb» ni "
                                     "un «#auto-lento/medio/rapido»."
                                     % (donde, que))
                    continue
            elif not que:
                que = "Macro %d" % i
            if valor is not None:
                macros.append((vector(canal, valor), marca + que))

    # Con qué valor arranca cada canal al abrir la consola.
    if arranque:
        macros.append(([arranque.get(c, -1) for c in range(1, n_canales + 1)],
                       "#omision"))

    # El prisma: con qué valor entra y con cuáles gira.
    for atributo, marca, canal in (
            ("prisma_puesto", "#prisma", prisma),
            ("prisma_rot_lento", "#giro-lento", canal_del_giro),
            ("prisma_rot_medio", "#giro-medio", canal_del_giro),
            ("prisma_rot_rapido", "#giro-rapido", canal_del_giro)):
        if valores[atributo] and canal:
            macros.append((vector(canal, valores[atributo]), marca))
    # El iris, solo si no es el de fábrica (255 abierto, 0 cerrado).
    for atributo, marca in (("iris_abierto", "#iris-abierto"),
                            ("iris_cerrado", "#iris-cerrado")):
        if canal_de["iris"] and valores[atributo] != IRIS_DE_FABRICA[atributo]:
            macros.append((vector(canal_de["iris"], valores[atributo]), marca))
    # Y la clase, que viaja dentro del archivo, pero solo si no es la que la
    # consola deduce sola: así la escribe ella al exportar, y así leer y
    # volver a escribir el perfil da el mismo perfil.
    if clase in CLASES and clase != clase_deducida(canal_de):
        macros.append(([-1] * n_canales, "#tipo:" + clase))

    if problemas:
        raise Problemas(problemas)

    # -- el .fxt, línea por línea, como lo escribe la consola --------------
    l = [fabricante, "Comments:", '"' + comentario + '"', nombre,
         str(n_canales), str(canal_de["pan"]), str(canal_de["tilt"]), imagen]

    def rueda(clave):
        l.append(str(canal_de[clave]))
        if canal_de[clave]:
            l.append(str(len(ruedas[clave])))
            for valor, que in ruedas[clave]:
                l.append(str(valor))
                l.append(que)

    for clave in ("gobo1", "color1", "gobo2", "color2"):
        rueda(clave)
    l += [str(canal_de["obturador"]), str(valores["obturador_abierto"]),
          str(valores["obturador_cerrado"])]
    if canal_de["rojo"] or canal_de["verde"] or canal_de["azul"]:
        l += ["1", str(canal_de["rojo"]), str(canal_de["verde"]),
              str(canal_de["azul"])]
    else:
        l.append("0")
    l += nombres
    l += [str(v) for v in (
        canal_de["strobe"], valores["strobe_lento"], valores["strobe_medio"],
        valores["strobe_rapido"], canal_de["dimmer"], canal_de["iris"],
        canal_de["foco"], canal_de["zoom"], canal_de["reset"],
        valores["reset_valor"], canal_de["gobo_rot1"], canal_de["gobo_rot2"],
        canal_de["prisma"])]
    l.append("Macros")
    l.append(str(len(macros) + 2))
    for vec, marca in macros:
        l += [str(v) for v in vec]
        l.append(marca)
    l += [str(canal_de["ambar"]), "0", str(canal_de["frost"])]
    rueda("gobo3")
    l += [str(canal_de["pan_fino"]), str(canal_de["tilt_fino"]), "0",
          str(canal_de["prisma_rot"]), "0", str(canal_de["lampara"]),
          str(valores["lampara_encender"]), str(valores["lampara_apagar"]),
          "0", "0", str(canal_de["niebla"]), str(canal_de["ventilador"])]
    l += COLA
    texto = "\r\n".join(l) + "\r\n"

    # -- las imágenes ------------------------------------------------------
    citadas = [imagen] + [que for clave in RUEDAS for _v, que in ruedas[clave]]
    imagenes, vistas = [], set()
    for citada in citadas:
        if not citada or citada.startswith("#") or citada in vistas:
            continue
        vistas.add(citada)
        ruta = os.path.join(carpeta_imagenes or ".",
                            citada.replace("\\", os.sep))
        if os.path.isfile(ruta):
            with open(ruta, "rb") as archivo:
                imagenes.append((citada, archivo.read()))
        else:
            avisos.append("No está «%s» junto al JSON: el .pff sale sin esa "
                          "imagen." % citada)

    resumen = ["%3d  %-24s %s" % (n, suyo, " + ".join(
        c for c in CLAVES if canal_de[c] == n) or (
        "(nombre suelto)" if suyo in NOMBRES_SUELTOS else ""))
        + ("   al iniciar: %d" % arranque[n] if n in arranque else "")
        for n, suyo in enumerate(nombres, start=1)]
    return Libreria(nombre_de_archivo(fabricante, nombre or "sin nombre"),
                    texto, imagenes, avisos, resumen)


def clase_deducida(canal_de):
    """La clase que la consola le supone por sus canales: se mueve, cabeza;
    humo o nada de luz, otro; lo demás, par. Un láser no se deduce."""
    if canal_de["pan"] or canal_de["tilt"]:
        return "cabeza"
    luz = ("dimmer", "rojo", "verde", "azul", "ambar", "obturador", "strobe")
    if canal_de["niebla"] or not any(canal_de[c] for c in luz):
        return "otro"
    return "par"


def _colores_de(que):
    if _COLOR.match(que):
        return [que]
    if _PARTIDO.match(que):
        return [que[:7], que[7:]]
    return []


def _posicion_rara(clave, que, gobos_antes):
    """Por qué esa posición no vale en esa rueda, o «»."""
    if clave in RUEDAS_DE_COLOR:
        # «» es un color que el manual no dice: se deja así (con aviso) y se
        # le pone el suyo en el editor, viendo la luz. Mejor que inventarlo.
        if que.startswith("#") and not (_COLOR.match(que)
                                        or _PARTIDO.match(que)
                                        or _AUTOMATICO.match(que)):
            return "«%s» no es un color ni un «#auto-...»." % que
        return ""
    if not que or not que.startswith("#"):
        return ""                    # gobo sin dibujo, o la foto de uno
    if que == "#ffffff" or _AUTOMATICO.match(que):
        return ""
    gobo = _GOBO.match(que)
    if gobo:
        return "" if gobo.group(1) in DIBUJOS else \
            "«%s» no es un dibujo. Los que hay: %s." % (
                gobo.group(1), ", ".join(DIBUJOS))
    shake = _SHAKE.match(que)
    if shake:
        if int(shake.group(1)) > gobos_antes:
            return ("«%s» tiembla el gobo %s, y antes de esta posición hay "
                    "%d gobo(s)." % (que, shake.group(1), gobos_antes))
        if shake.group(2) and shake.group(2) not in DIBUJOS:
            return "«%s» no es un dibujo." % shake.group(2)
        return ""
    return ("en una rueda de gobos va «#ffffff» (abierto), «» (gobo sin "
            "dibujo), «#gobo-...», «#shake-N», «#auto-...» o el nombre de "
            "una foto; «%s» no es nada de eso." % que)


def main(argumentos):
    if not argumentos or argumentos[0] in ("-h", "--help"):
        print(__doc__)
        return 0
    ruta_json = argumentos[0]
    salida = argumentos[1] if len(argumentos) > 1 else \
        os.path.dirname(os.path.abspath(ruta_json))
    with open(ruta_json, encoding="utf-8") as archivo:
        descripcion = json.load(archivo)
    try:
        libreria = armar(descripcion,
                         os.path.dirname(os.path.abspath(ruta_json)))
    except Problemas as problemas:
        print(problemas)
        return 1
    os.makedirs(salida, exist_ok=True)
    with open(os.path.join(salida, libreria.nombre_fxt), "wb") as archivo:
        archivo.write(libreria.bytes_fxt())
    with open(os.path.join(salida, libreria.nombre_pff), "wb") as archivo:
        archivo.write(libreria.bytes_pff())
    print("\n".join(libreria.resumen))
    print("\nListo: %s y %s en %s" % (libreria.nombre_fxt,
                                      libreria.nombre_pff, salida))
    for aviso in libreria.avisos:
        print("Aviso: " + aviso)
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
```
<!-- programa: fin -->

Guía de DMX Total-Pro 1.9.1 · soft-laguna.com
