Files
labstream-organizador/empaquetar.py
Labstream 0819f2da89 3.4.0: los BRAW se convierten sin Resolve, y lo que no compensa no se reconvierte
Munka no podía reconvertir archivos que antes sí. Auditado sobre la carpeta de Andrea Galeano:

- Los BRAW nunca habían salido: 87 clips (496 GB), tres tandas por el guion de Resolve y ni un
  clip convertido. Ahora los lee el decodificador de Blackmagic que instala Resolve (braw.py,
  en un proceso aparte), sin abrir Resolve, y ffmpeg los codifica como cualquier otro clip.
  Probado con clips del NAS: 15,6 GB → 394 MB con sus 7.004 cuadros, VMAF 96,0 contra el BRAW
  revelado sin pérdida. Las conversiones de BRAW que fallaron por Resolve pasan solas al lector.
- Lo que «antes podía» eran reconversiones de lo ya convertido: ahorraban un 5 % con pérdida.
  Siguen sin ofrecerse, y lo que ya viene comprimido de cámara solo se ofrece si compensa.
- La tarjeta codifica con «uhq»: entre un 19 y un 55 % menos de peso con la misma calidad
  medida, siempre H.265 de 10 bits.
- Una conversión que no aligera (más del 80 % del clip, o reconversión de una conversión) no
  reemplaza al original: queda «no compensa» y lo que salió va a _Para borrar\descartados.

De paso: los MP4 guardan las etiquetas de la cámara; un intento que no pasó la verificación se
puede reintentar; un H.264 de más de 4.096 px de lado va por CPU; el motivo de un fallo se lee
entero; el guion de Resolve llega con tildes y borra su ficha.

Pruebas: prueba_orden.py 33 (una con un BRAW de verdad), probar.py 19, prueba_funcional.py 30,
prueba_ventana.py 18 desde el código y 17 con el paquete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:24:34 -05:00

257 líneas
15 KiB
Python
Original Blame Histórico

Este archivo contiene caracteres Unicode ambiguos

Este archivo contiene caracteres Unicode que pueden confundirse con otros caracteres. Si crees que esto es intencional, puedes ignorar esta advertencia. Usa el botón de Escape para revelarlos.

# -*- coding: utf-8 -*-
r"""
Construye el paquete portátil y el de actualización.
Uso: py -3.12 empaquetar.py [ruta\a\ffmpeg\bin]
Resultado:
dist\OrganizadorLabstream\ carpeta completa (con ffmpeg)
OrganizadorLabstream-portatil-<v>.zip primera instalación (con ffmpeg, ~190 MB)
OrganizadorLabstream-app-<v>.zip actualización (sin ffmpeg ni datos, ~15 MB)
..\labstream-os\public\herramientas\organizador.json manifiesto para el OS (versión, tamaños, sha256)
Después: subir los dos zips al NAS (storage/herramientas/) y desplegar el OS.
El paquete no lleva ningún binario propio. OrganizadorLabstream.exe es pythonw.exe con otro nombre
(arranque.py cuenta cómo arranca el programa), y todo lo demás que se ejecuta lo firma quien lo hizo:
la Python Software Foundation o Microsoft. El programa es su código, a la vista. Hasta la 2.x se
congelaba con PyInstaller y salía un .exe sin firma que Smart App Control bloqueaba.
Antes de dar el paquete por bueno se le pregunta a Windows por la firma de cada binario (la misma
comprobación que hace al abrirlo) y se arranca el motor del paquete: si algo falla, no hay paquete.
"""
import os
import re
import sys
import glob
import json
import uuid
import ctypes
import shutil
import hashlib
import zipfile
import datetime
import tempfile
import py_compile
import subprocess
from ctypes import wintypes as W
AQUI = os.path.dirname(os.path.abspath(__file__))
os.chdir(AQUI)
VERSION = re.search(r'^VERSION = "([^"]+)"', open("organizador.py", encoding="utf-8").read(), re.M).group(1)
print("versión", VERSION)
MODULOS = ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "braw.py", "firma.py",
"ventana.py", "instalar.py")
PY = sys.base_prefix # el Python con el que se empaqueta
MOTOR = f"python{sys.version_info.major}{sys.version_info.minor}" # python312
# de la librería estándar no viaja lo que el programa no usa ni va a usar
SIN_LIB = {"site-packages", "test", "idlelib", "tkinter", "turtledemo", "ensurepip", "venv", "lib2to3", "pydoc_data", "__pycache__"}
SIN_DLL = ("_tkinter", "tcl", "tk8", "zlib1", "_test", "_ctypes_test", "xxlimited")
ffbin = sys.argv[1] if len(sys.argv) > 1 else ""
if not ffbin:
for c in glob.glob(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin")):
ffbin = c
if not ffbin and shutil.which("ffmpeg"):
ffbin = os.path.dirname(shutil.which("ffmpeg"))
print("ffmpeg desde:", ffbin or "(no encontrado: el zip irá sin ffmpeg)")
def firmado(ruta):
"""True si Windows da por buena la firma del archivo."""
class Archivo(ctypes.Structure):
_fields_ = [("cbStruct", W.DWORD), ("pcwszFilePath", W.LPCWSTR), ("hFile", W.HANDLE), ("pgKnownSubject", ctypes.c_void_p)]
class Datos(ctypes.Structure):
_fields_ = [("cbStruct", W.DWORD), ("pPolicyCallbackData", ctypes.c_void_p), ("pSIPClientData", ctypes.c_void_p),
("dwUIChoice", W.DWORD), ("fdwRevocationChecks", W.DWORD), ("dwUnionChoice", W.DWORD),
("pFile", ctypes.POINTER(Archivo)), ("dwStateAction", W.DWORD), ("hWVTStateData", W.HANDLE),
("pwszURLReference", W.LPCWSTR), ("dwProvFlags", W.DWORD), ("dwUIContext", W.DWORD),
("pSignatureSettings", ctypes.c_void_p)]
a = Archivo(ctypes.sizeof(Archivo), ruta, None, None)
d = Datos()
d.cbStruct, d.dwUIChoice, d.dwUnionChoice, d.pFile, d.dwProvFlags = ctypes.sizeof(Datos), 2, 1, ctypes.pointer(a), 0x1000
accion = (ctypes.c_ubyte * 16)(*uuid.UUID("{00AAC56B-CD44-11D0-8CC2-00C04FC295EE}").bytes_le) # «comprueba la firma»
f = ctypes.WinDLL("wintrust").WinVerifyTrust
f.argtypes, f.restype = [W.HWND, ctypes.c_void_p, ctypes.c_void_p], ctypes.c_long
return f(None, ctypes.byref(accion), ctypes.byref(d)) == 0
shutil.rmtree("build", ignore_errors=True) # restos de cuando se empaquetaba con PyInstaller
shutil.rmtree("dist", ignore_errors=True)
dest = os.path.join("dist", "OrganizadorLabstream")
motor = os.path.join(dest, "runtime")
os.makedirs(motor)
# ── el motor: el Python de este equipo, tal cual lo instala python.org ──
shutil.copy2(os.path.join(PY, "pythonw.exe"), os.path.join(dest, "OrganizadorLabstream.exe"))
for f in (MOTOR + ".dll", "vcruntime140.dll", "vcruntime140_1.dll"):
if os.path.exists(os.path.join(PY, f)):
shutil.copy2(os.path.join(PY, f), dest)
for f in sorted(os.listdir(os.path.join(PY, "DLLs"))):
if f.lower().endswith((".pyd", ".dll")) and not f.lower().startswith(SIN_DLL):
shutil.copy2(os.path.join(PY, "DLLs", f), motor)
# la librería estándar, compilada y en un zip (como la distribución «embeddable» de python.org)
lib = os.path.join(PY, "Lib")
with tempfile.TemporaryDirectory() as tmp, zipfile.ZipFile(os.path.join(motor, MOTOR + ".zip"), "w", zipfile.ZIP_DEFLATED) as z:
n = 0
for raiz, dirs, files in os.walk(lib):
dirs[:] = sorted(d for d in dirs if d not in SIN_LIB)
for f in sorted(files):
if not f.endswith(".py"):
continue
rel = os.path.relpath(os.path.join(raiz, f), lib).replace(os.sep, "/")
pyc = os.path.join(tmp, "m.pyc")
py_compile.compile(os.path.join(raiz, f), cfile=pyc, dfile=rel, doraise=True,
invalidation_mode=py_compile.PycInvalidationMode.UNCHECKED_HASH)
z.write(pyc, rel + "c")
n += 1
print(f"motor: Python {sys.version.split()[0]} · {n} módulos de la librería estándar")
# qué carpetas mira el motor, y nada más (ni el registro ni las variables de entorno de este equipo)
with open(os.path.join(dest, MOTOR + "._pth"), "w", encoding="ascii") as fh:
fh.write(f"runtime\\{MOTOR}.zip\nruntime\n.\nimport site\n")
shutil.copy("arranque.py", os.path.join(motor, "sitecustomize.py"))
if os.path.exists(os.path.join(PY, "LICENSE.txt")):
shutil.copy(os.path.join(PY, "LICENSE.txt"), os.path.join(motor, "LICENCIA-Python.txt"))
# ── el programa ──
for f in MODULOS:
shutil.copy(f, dest)
shutil.copy("WebView2Loader.dll", dest)
shutil.copytree("static", os.path.join(dest, "static"))
# ── comprobaciones: todo firmado, y el motor del paquete arranca ──
binarios = [os.path.join(r, f) for r, _, fs in os.walk(dest) for f in fs if f.lower().endswith((".exe", ".dll", ".pyd"))]
sin_firma = [os.path.relpath(b, dest) for b in binarios if not firmado(os.path.abspath(b))]
if sin_firma:
sys.exit("estos binarios no pasan la comprobación de firma de Windows; no se empaqueta: " + ", ".join(sin_firma))
print(f"firmas: {len(binarios)} binarios, todos firmados")
exe = os.path.abspath(os.path.join(dest, "OrganizadorLabstream.exe"))
p = subprocess.run([exe, "-B", "-c", "import sqlite3, ssl, ctypes, hashlib, zipfile, http.server, urllib.request, winreg; "
"import reglas, firma, ventana, instalar; print(ventana.disponible('.')[0])"],
cwd=dest, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
if p.returncode != 0 or p.stdout.strip() not in ("True", "False"):
sys.exit(f"el motor del paquete no arranca (código {p.returncode}): {p.stderr.strip() or p.stdout.strip()}")
p = subprocess.run([exe, "ayuda"], cwd=dest, capture_output=True, timeout=120, stdin=subprocess.DEVNULL)
if p.returncode != 0 or b"OrganiLAB" not in p.stdout:
sys.exit(f"el paquete no responde a «ayuda» (código {p.returncode}): {p.stderr.decode('utf-8', 'replace').strip()}")
for resto in ("__pycache__", "datos"): # lo que dejó el arranque de prueba
shutil.rmtree(os.path.join(dest, resto), ignore_errors=True)
print("arranque: el motor del paquete carga sus módulos y el programa responde")
# ── lo que acompaña al programa: datos de partida, instrucciones y los .bat de arrastrar ──
os.makedirs(os.path.join(dest, "datos"), exist_ok=True)
for f in ("config.json", "diccionario.json", "plantillas.json"):
src = os.path.join("datos", f)
if os.path.exists(src):
shutil.copy(src, os.path.join(dest, "datos", f))
# la config del paquete sale limpia: sin el recorrido ya visto, sin raíces de prueba y sin lo que es de este equipo
cfg_p = os.path.join(dest, "datos", "config.json")
if os.path.exists(cfg_p):
cfg = json.load(open(cfg_p, encoding="utf-8"))
for k in ("tour_visto", "instalacion"):
cfg.pop(k, None)
cfg["raices"] = [] # el paquete sale SIN carpetas: cada equipo añade las suyas y se escanean al añadirlas
json.dump(cfg, open(cfg_p, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
shutil.copy("README.md", dest)
BAT = ('@echo off\r\nchcp 65001 >nul\r\ncd /d "%~dp0"\r\nif "%~1"=="" (\r\n echo Arrastra una carpeta de cliente encima de este archivo.\r\n pause\r\n exit /b\r\n)\r\n'
'"%~dp0OrganizadorLabstream.exe" {cmd} "%~1"\r\n')
with open(os.path.join(dest, "Organizar carpeta (arrastra aqui).bat"), "w", encoding="cp1252") as fh:
fh.write(BAT.format(cmd="asistente"))
with open(os.path.join(dest, "Apartar stock Envato viejo (arrastra aqui).bat"), "w", encoding="cp1252") as fh:
fh.write(BAT.format(cmd="stock"))
with open(os.path.join(dest, "LEEME.txt"), "w", encoding="utf-8") as fh:
fh.write(f"OrganiLAB · Organizador Labstream {VERSION}\n\n"
"ABRIRLO: doble clic en OrganizadorLabstream.exe. Se abre en su propia ventana, como cualquier programa de Windows.\n"
"La primera vez pregunta si lo instalas: se copia a tu carpeta de programas, con sus datos, y queda en el menú\n"
"Inicio y en el Escritorio. Desde entonces se abre desde ahí, y esta carpeta ya no hace falta: se puede borrar.\n"
"Se quita en Configuración › Aplicaciones. También: OrganizadorLabstream.exe instalar / desinstalar.\n\n"
"MANUAL: dentro del programa, arriba a la derecha, «Manual»: cada función, qué hace y cómo lo hace.\n\n"
"En el Explorador el .exe sale con el icono de Python: es el motor que lo mueve, firmado por la Python Software\n"
"Foundation, y por eso Windows lo deja abrir también con Smart App Control encendido. En Inicio, en el Escritorio\n"
"y en la barra de tareas sale con su icono.\n\n"
"ORDENAR UNA CARPETA SIN ABRIR EL PROGRAMA: arrastra la carpeta del cliente encima de «Organizar carpeta (arrastra aqui).bat»\n"
"(o del propio .exe). Se escanea con barra de progreso, sale el diagnóstico y el asistente pregunta paso a paso.\n"
"Por consola: OrganizadorLabstream.exe asistente \"D:\\Clientes\\Carpeta\" [--simular] [--si]\n"
"Más comandos: OrganizadorLabstream.exe ayuda\n\n"
"1. No hace falta instalar nada: Python y ffmpeg van dentro.\n"
"2. Todo ocurre en este equipo. El programa no sale a la red salvo que le pidas buscar una versión nueva.\n"
"3. Para convertir BRAW hace falta DaVinci Resolve Studio abierto en un proyecto.\n"
"4. La app NUNCA borra: lo que sobra va a _Para borrar y lo repetido a subcarpetas Duplicados, para que una persona lo revise.\n"
"5. Todo lo que aprende y la configuración queda en la carpeta datos\\ (llévala contigo si cambias de PC).\n")
with open(os.path.join(motor, "AVISOS.txt"), "w", encoding="utf-8") as fh:
fh.write("Lo que viaja en este paquete y no es de Labstream:\n\n"
f"- Python {sys.version.split()[0]} (OrganizadorLabstream.exe, {MOTOR}.dll y esta carpeta): Python Software Foundation.\n"
" Licencia en LICENCIA-Python.txt.\n"
"- WebView2Loader.dll: Microsoft Corporation. Se distribuye con el programa según la licencia del SDK de WebView2.\n"
"- vcruntime140*.dll: Microsoft Corporation (Visual C++ Redistributable).\n"
"- ffmpeg\\ (solo en el paquete completo): ver ffmpeg\\LICENSE.txt.\n")
# ── paquete APP (actualización): todo menos ffmpeg y datos ──
app_zip = f"OrganizadorLabstream-app-{VERSION}.zip"
with zipfile.ZipFile(app_zip, "w", zipfile.ZIP_DEFLATED) as z:
for raiz, dirs, files in os.walk(dest):
rel_raiz = os.path.relpath(raiz, dest)
primero = rel_raiz.split(os.sep)[0]
if primero in ("ffmpeg", "datos"):
continue
for f in files:
p = os.path.join(raiz, f)
z.write(p, "OrganizadorLabstream/" + os.path.relpath(p, dest).replace(os.sep, "/"))
# ── paquete COMPLETO (primera instalación): con ffmpeg ──
if ffbin:
os.makedirs(os.path.join(dest, "ffmpeg"), exist_ok=True)
for exe_ff in ("ffmpeg.exe", "ffprobe.exe"):
shutil.copy(os.path.join(ffbin, exe_ff), os.path.join(dest, "ffmpeg", exe_ff))
lic = os.path.join(os.path.dirname(ffbin), "LICENSE")
if os.path.exists(lic):
shutil.copy(lic, os.path.join(dest, "ffmpeg", "LICENSE.txt"))
for viejo in glob.glob("OrganizadorLabstream-portatil*.zip") + [z for z in glob.glob("OrganizadorLabstream-app-*.zip") if z != app_zip]:
os.remove(viejo)
full_zip = f"OrganizadorLabstream-portatil-{VERSION}.zip"
shutil.make_archive(full_zip[:-4], "zip", "dist", "OrganizadorLabstream")
def sha(p):
h = hashlib.sha256()
with open(p, "rb") as fh:
for b in iter(lambda: fh.read(1 << 20), b""):
h.update(b)
return h.hexdigest()
notas = os.environ.get("NOTAS", "")
manifiesto = {
"nombre": "OrganiLAB",
"version": VERSION,
"builtAt": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"archivo": full_zip, "size": os.path.getsize(full_zip), "sha256": sha(full_zip),
"app": {"archivo": app_zip, "size": os.path.getsize(app_zip), "sha256": sha(app_zip), "url": f"/api/herramientas/{app_zip}"},
"notes": notas,
}
import firma
if os.environ.get("FIRMA") == "no":
print("AVISO: manifiesto sin firmar (FIRMA=no); los .exe 1.6.2+ no lo aceptarán")
else:
if not os.path.exists(firma.RUTA_PRIVADA):
sys.exit(f"falta la clave de firma en {firma.RUTA_PRIVADA}: corre py -3.12 firma.py generar (o FIRMA=no para saltarlo)")
_priv = firma.cargar_privada()
manifiesto["firma"] = {"alg": "rsa2048-pkcs1v15-sha256", "valor": firma.firmar(firma.texto_manifiesto(manifiesto), _priv)}
import actualizador
if not firma.verificar(firma.texto_manifiesto(manifiesto), manifiesto["firma"]["valor"], actualizador.CLAVE_PUBLICA):
sys.exit("la clave privada no corresponde a CLAVE_PUBLICA de actualizador.py; el manifiesto no serviría")
print("manifiesto firmado")
ruta_os = os.path.join(os.path.dirname(AQUI), "labstream-os", "public", "herramientas", "organizador.json")
if os.path.isdir(os.path.dirname(ruta_os)) and os.environ.get("SOLO_LOCAL") != "si":
previo = {}
try:
previo = json.load(open(ruta_os, encoding="utf-8"))
except Exception: # noqa
pass
if not notas:
manifiesto["notes"] = previo.get("notes", "") if previo.get("version") == VERSION else ""
json.dump(manifiesto, open(ruta_os, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print("manifiesto escrito en", ruta_os)
json.dump(manifiesto, open("manifiesto.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"listo: {full_zip} ({os.path.getsize(full_zip) / 1e6:.0f} MB) · {app_zip} ({os.path.getsize(app_zip) / 1e6:.0f} MB)")
print("Sube los dos zips a storage/herramientas/ del NAS y despliega el OS.")