Convertir: lista por carpetas con cuánto falta, «comparar» con nota de calidad, y un botón principal según el momento

Munka eligió tres de seis propuestas dibujadas en un lienzo (quedan el horario nocturno, el resumen
al acabar y marcar con criterio):
- La lista va por carpetas (la ruta una sola vez), el clip que se convierte lleva su barra y lo que le
  queda, «primero este» adelanta uno, y los excluidos salen en grupos con su porqué. Antes y después en
  una barra. La cola mide su ritmo y dice cuánto falta y a qué hora acaba (eta en /api/optimizar/cola).
- «Comparar»: el mismo cuadro del original y del convertido en tres instantes, con lupa, y «Medir la
  calidad» (VMAF) que se guarda en el trabajo; braw.py cuadro y braw.py calidad revelan el BRAW con la
  misma curva. Medido: 97,8 en 14 s con un BRAW de 16,5 GB.
- Un solo botón grande según el momento: buscar, convertir (con «reemplazar solo cuando estén
  verificados», optimizar.auto_reemplazar), la tanda con Detener, reanudar, o reemplazar lo verificado.

Pruebas: probar 19, prueba_funcional 31 (nueva: comparar), prueba_ventana 18.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-12 11:43:05 -05:00
padre d92814d16c
commit d38c51bd73
Se han modificado 5 ficheros con 544 adiciones y 40 borrados

Ver fichero

@@ -345,7 +345,47 @@
.nombrar li .r{font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
details.mas>summary{cursor:pointer;color:var(--mut);font-size:12.5px;margin:8px 0}
/* convertir */
.cfila{display:grid;grid-template-columns:24px 110px 1fr 150px 80px;gap:10px;padding:7px 0;border-bottom:1px solid var(--line2);font-size:12.5px;align-items:center}
.cfila{display:grid;grid-template-columns:24px 110px minmax(0,1fr) 150px 70px 110px;gap:10px;padding:7px 0;border-bottom:1px solid var(--line2);font-size:12.5px;align-items:center}
.cfila .acts{text-align:right;white-space:nowrap}
.cfila .acts button{background:none;border:0;color:var(--acc);font-size:12px;font-weight:600;padding:0 4px;cursor:pointer}
.cfila .nom .sub2 .carp{font-family:ui-monospace,Consolas,monospace}
.cgrupo{display:flex;gap:10px;align-items:center;padding:12px 0 6px;font-size:12.5px;border-top:1px solid var(--line);margin-top:6px}
.cgrupo.primero{border-top:0;margin-top:0}
.cgrupo .pl{background:none;border:0;padding:0;color:var(--mut);width:14px;cursor:pointer;display:inline-flex}
.cgrupo .cab{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:700;color:var(--mut);white-space:nowrap}
.cgrupo .cab.corre{color:var(--acc)}
.excl{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:12px 0 4px;border-top:1px dashed var(--line);margin-top:6px;font-size:12.5px;color:var(--mut)}
.excl button{background:var(--panel2);border:1px solid var(--line);border-radius:99px;padding:1px 9px;font-size:12px;color:var(--tx);cursor:pointer}
.excl button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)}
.excl button.ojo{background:var(--warn2);color:var(--warn)}
.momento{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:8px}
.momento.corre{border-color:var(--acc)} .momento.listo{border-color:var(--ok)}
.momento .l1{display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13.5px}
.momento .l3{font-size:12.5px}
.momento .bar.grande{height:10px}
.momento button.grande{font-size:14px;padding:9px 16px;border-radius:6px;font-weight:700}
.momento .ico{width:22px;height:22px;border-radius:50%;background:var(--ok2);color:var(--ok);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:13px}
.antes{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:6px}
.antes .l1,.antes .l3{display:flex;gap:16px;align-items:center;flex-wrap:wrap;font-size:12.5px}
.antes .l2{display:flex;gap:12px;align-items:center}
.antes .l2 b{font-size:22px;white-space:nowrap}.antes .l2 b.acc{color:var(--acc)}
.antes .pista{flex:1;height:18px;background:var(--panel2);border-radius:99px;overflow:hidden;display:flex}
.antes .pista i.conv{display:block;height:100%;background:var(--acc);border-radius:99px 0 0 99px}
.antes .pista i.libre{display:block;height:100%;background:var(--ok2);border-right:2px solid var(--ok)}
.antes .sw{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:-1px;margin-right:4px}.antes .sw.conv{background:var(--acc)}.antes .sw.libre{background:var(--ok2);border:1px solid var(--ok)}
.modal.ancho .box{max-width:1200px}
.cmp{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.cmp .lado{display:flex;flex-direction:column;gap:6px;min-width:0}
.cmp .marco{position:relative;background:#d9dcdf;border-radius:8px;overflow:hidden;aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#4d5157;font-size:12.5px}
.cmp .marco img{width:100%;height:100%;object-fit:contain;display:block;cursor:crosshair}
.cmp .lupa{position:absolute;width:220px;height:220px;border:2px solid var(--acc);border-radius:8px;background-repeat:no-repeat;background-color:#000;pointer-events:none;display:none;box-shadow:0 4px 14px rgba(0,0,0,.3)}
.cmp .marco.con .lupa{display:block}
.cmpnota{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:12px}
.cmpnota>div{border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-size:12.5px;display:flex;flex-direction:column;gap:4px}
.cmpnota b.n{font-size:26px;color:var(--ok)} .cmpnota b.n.baja{color:var(--warn)}
.cmpt{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px;color:var(--mut);margin-top:10px}
.cmpt button{font-size:12px;padding:2px 8px}
.cmpt button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)}
.cfila:last-child{border-bottom:0}
.cfila .nom{min-width:0}
.cfila .nom .clip{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
@@ -581,7 +621,8 @@
</div></details></span></div>
<p class="ayuda">Convierte los clips de proyectos ya entregados a un formato más ligero. <b>El original no se toca</b> hasta que reemplaces. <span class="q">?</span><span class="mas">Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a <span class="path">_Para borrar/originales</span> y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW se leen con el decodificador de Blackmagic que instala DaVinci Resolve, sin tener que abrirlo; todo se codifica con ffmpeg, con la tarjeta gráfica cuando la hay. Lo que ya viene comprimido de cámara (H.264 o H.265 de pocos megabits) no se ofrece: ahorraría poco y perdería calidad. Nada se borra.</span></p>
<div class="toolbar" style="margin-bottom:10px"><label>formato de salida <select id="optFormato"></select></label><span id="fmtQue" class="mut" style="font-size:12px"></span><span class="sep"></span><span id="motores" class="mut" style="font-size:12px"><span class="spin"></span> detectando GPU y CPU…</span></div>
<div class="cards c4" id="optCards"></div>
<div id="optCards"></div>
<div id="momento"></div>
<div class="toolbar">
<button id="btnBuscarClips" class="p">Buscar clips</button>
<button id="btnEncolar" disabled>Convertir</button>
@@ -682,9 +723,10 @@
<h3>Convertidor</h3>
<dl>
<dt>Comparar</dt><dd>En un clip convertido, enseña el mismo cuadro del original y del convertido, lado a lado, en tres instantes del clip; al pasar el ratón, una lupa amplía el mismo recorte en los dos. «Medir la calidad» da la nota de 0 a 100 (VMAF) sobre unos diez segundos del medio del clip; de 93 arriba no se nota a simple vista. Desde ahí se reemplaza ese original solo. <i>Cómo: un BRAW se revela con la misma curva con que se convirtió; si el original ya se reemplazó, se busca en _Para borrar/originales. La nota se guarda en el trabajo y sale en la lista.</i></dd>
<dt>Buscar clips</dt><dd>Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. <i>Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero, los exportados y lo que ya convirtió este programa, para no pasar un clip dos veces.</i></dd>
<dt>Formato y calidad</dt><dd>H.265 10 bits (el recomendado, unas diez veces más ligero), H.265 ligero, H.264 o ProRes proxy, LT y HQ; calidad de ahorro, equilibrada o máxima. «Motores» te dice qué acelera tu tarjeta gráfica.</dd>
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. La lista es una sola: cada clip con su estado de verdad (candidato, en espera, convirtiendo con su barra, convertido, verificado, falló); arriba se elige qué ver (por convertir, en cola, terminados, con fallo, excluidos) y por qué ordenar (estado, nombre, fecha, tamaño, ahorro; otra vez invierte el orden). <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW los lee el decodificador de Blackmagic que viene con DaVinci Resolve, sin abrirlo. No se ofrece lo que ya viene comprimido de cámara ni lo que ya convirtió el programa: reconvertirlo ahorra poco y pierde calidad.</i></dd>
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Arriba hay un solo botón grande, el que toca en ese momento: buscar, convertir, detener la tanda (con cuánto falta y a qué hora acaba) o reemplazar lo verificado. La lista es una sola, por carpetas: cada clip con su estado de verdad (candidato, en espera, convirtiendo con su barra y lo que le queda, convertido, verificado, falló); «primero este» adelanta un clip; se elige qué ver (por convertir, en cola, terminados, con fallo, excluidos, y los excluidos por grupos con su porqué) y por qué ordenar (carpeta, estado, nombre, fecha, tamaño, ahorro; otra vez invierte el orden). <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW los lee el decodificador de Blackmagic que viene con DaVinci Resolve, sin abrirlo. No se ofrece lo que ya viene comprimido de cámara ni lo que ya convirtió el programa: reconvertirlo ahorra poco y pierde calidad.</i></dd>
<dt>Verificar</dt><dd>Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.</dd>
<dt>Reemplazar</dt><dd>El convertido ocupa el sitio del original, con su misma fecha, y el original se aparta a <span class="path">_Para borrar\originales</span>.</dd>
</dl>
@@ -1563,7 +1605,21 @@ $('#btnStockPapelera').onclick = () => { const s = STOCK.filter(g => g.sel && g.
// ---------------- convertir ----------------
function pintarCriterio(){ $('#convCriterio').textContent = `con más de ${+$('#optMeses').value || 0} meses y más de ${+$('#optMin').value || 0} MB`; }
['optMeses','optMin'].forEach(id => $('#' + id).oninput = pintarCriterio);
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'estado', inverso: false};
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'carpeta', inverso: false}, CONV_EXC = '', CONV_PLEGADAS = new Set();
const tiempoLargo = s => { s = Math.max(0, Math.round(s || 0)); if (s < 60) return `${s} s`; if (s < 3600) return `${Math.floor(s / 60)} min${s % 60 ? ' ' + (s % 60) + ' s' : ''}`; return `${Math.floor(s / 3600)} h${Math.round(s % 3600 / 60) ? ' ' + Math.round(s % 3600 / 60) + ' min' : ''}`; };
// por qué no se ofrece un clip, en grupos: 502 filas grises con un «no» no explican nada
function claveExc(m){
m = m || '';
if (m.startsWith('ya es una conversión')) return 'ya los convirtió el programa';
if (m.startsWith('ya viene comprimido')) return 'ya vienen comprimidos de cámara';
if (m.startsWith('ya se probó')) return 'ya se probaron con esta calidad';
if (m.startsWith('tiene menos de')) return m.replace('tiene menos de', 'de menos de');
if (m.startsWith('no se pudo leer')) return 'no se pudieron leer';
if (m.startsWith('es un exportado')) return 'exportados y masters';
if (m.startsWith('stock')) return 'stock de Envato';
if (m.startsWith('tiene canal alfa')) return 'con canal alfa';
return m.split(':')[0];
}
$('#btnBuscarClips').onclick = () => accion($('#btnBuscarClips'), async () => {
const cv = (CARPETA && CARPETA.convertir) || {};
if (cv.sin_analizar > 0 || (cv.analisis && cv.analisis.estado === 'error')) {
@@ -1614,10 +1670,13 @@ async function cargarCola(){
bloquear('btnReemplazar', verif ? '' : (listos ? 'hay convertidos sin verificar' : 'x')); $('#btnReemplazar').textContent = verif ? `Reemplazar ${verif} verificado${verif > 1 ? 's' : ''}` : 'Reemplazar';
const validos = CAND.filter(c => !c.excluido), ahora = validos.reduce((s,c)=>s+c.bytes,0), despues = validos.reduce((s,c)=>s+c.estimado,0);
const hechosB = ts.filter(t => t.bytes_out).reduce((s,t) => s + (t.bytes_in - t.bytes_out), 0);
$('#optCards').innerHTML = (CAND.length || ts.length) ? [[validos.length, 'clips convertibles'], [gb(ahora), 'pesan hoy'], [gb(despues), 'pesarían (estimado)'], [hechosB ? (hechosB > 0 ? '−' : '+') + gb(Math.abs(hechosB)) : `${verif}`, hechosB > 0 ? `ya liberados · ${verif} verificados` : hechosB < 0 ? `de más (formato de edición) · ${verif} verificados` : 'verificados']].map(([b,s]) => `<div class="card"><b>${b}</b><small>${s}</small></div>`).join('') : '';
const reemp = n('reemplazado');
// antes y después, en una barra: lo que pesa hoy, lo que pesará, y lo que se libera
const pConv = Math.max(2, Math.min(98, Math.round(despues / (ahora || 1) * 100)));
$('#optCards').innerHTML = (CAND.length || ts.length) ? `<div class="antes"><div class="l1"><span class="cabsec" style="margin:0">Antes y después</span><span class="sep"></span><span class="mut">${validos.length} clip${validos.length !== 1 ? 's' : ''} convertible${validos.length !== 1 ? 's' : ''}</span></div>${ahora ? `<div class="l2"><b>${gb(ahora)}</b><div class="pista"><i class="conv" style="width:${pConv}%"></i><i class="libre" style="width:${100 - pConv}%"></i></div><b class="acc">${gb(despues)}</b></div><div class="l3 mut"><span><i class="sw conv"></i>lo que pesarán los convertidos</span><span><i class="sw libre"></i>lo que se libera: ${gb(Math.max(0, ahora - despues))}</span><span class="sep"></span><span>${hechosB > 0 ? `<b class="ok">${gb(hechosB)} menos</b> en ${verif + reemp} ya convertidos${reemp ? '' : ' (se libera al reemplazar)'}` : `${verif} verificados`}</span></div>` : `<div class="l3 mut"><span>${hechosB > 0 ? `<b class="ok">${gb(hechosB)} menos</b> en ${verif + reemp} clips convertidos${reemp ? '' : ' (se libera al reemplazar)'}` : 'nada convertible ahora mismo'}</span></div>`}</div>` : '';
// Una sola lista: los candidatos con el estado VIVO de su trabajo (la ficha de «Buscar» se quedaba con
// «corriendo» en un clip que ya había acabado), más los trabajos de clips que ya no son candidatos. Se
// ordena y se filtra desde la barra de arriba, y el que se está convirtiendo lleva su barra en la casilla.
// «corriendo» en un clip que ya había acabado), más los trabajos de clips que ya no son candidatos. Va por
// carpetas (la ruta una sola vez) o plana si se ordena por otra cosa, y el que se convierte lleva su barra.
const fmt = $('#optFormato').value || 'h265_10bit';
const porRuta = {};
ts.forEach(t => { const k = (t.ruta || '').toLowerCase(); if (!porRuta[k] || (orden[t.estado] ?? 9) < (orden[porRuta[k].estado] ?? 9)) porRuta[k] = t; });
@@ -1626,52 +1685,133 @@ async function cargarCola(){
const vistas = new Set(CAND.map(c => (c.ruta || '').toLowerCase()));
ts.forEach(t => { if (!vistas.has((t.ruta || '').toLowerCase())) filas.push({c: null, i: -1, t}); });
resumenCand();
const nombre = f => f.c ? f.c.rel : (f.t.ruta || '').split(/[\\/]/).pop();
const relDe = f => f.c ? f.c.rel : (f.t.raiz && (f.t.ruta || '').toLowerCase().startsWith(f.t.raiz.toLowerCase()) ? f.t.ruta.slice(f.t.raiz.length).replace(/^[\\/]/, '') : (f.t.ruta || ''));
const nombre = f => ultimo(relDe(f));
const carpeta = f => padreDe(relDe(f));
const bytes = f => f.c ? f.c.bytes : (f.t.bytes_in || 0);
const sale = f => f.t && f.t.bytes_out ? f.t.bytes_out : f.c ? f.c.estimado : 0;
const ahorro = f => bytes(f) && sale(f) ? 1 - sale(f) / bytes(f) : -9;
const rango = f => f.t ? ({corriendo: 0, pendiente: 1, listo: 3, verificado: 3, error: 4, descartado: 5, reemplazado: 6, cancelado: 6}[f.t.estado] ?? 7) : f.c.excluido ? 8 : 2;
const grupo = f => f.t && ['pendiente', 'corriendo'].includes(f.t.estado) ? 'cola' : f.t && ['listo', 'verificado', 'reemplazado'].includes(f.t.estado) ? 'hechos' : f.t && ['error', 'cancelado', 'descartado'].includes(f.t.estado) ? 'fallos' : f.c && f.c.excluido ? 'no' : 'libres';
const CLAVES = {estado: (a, b) => rango(a) - rango(b) || bytes(b) - bytes(a), nombre: (a, b) => nombre(a).localeCompare(nombre(b), 'es'), fecha: (a, b) => ((b.c || {}).fecha || '').localeCompare((a.c || {}).fecha || '') || bytes(b) - bytes(a), tamano: (a, b) => bytes(b) - bytes(a), ahorro: (a, b) => ahorro(b) - ahorro(a)};
const lista = filas.filter(f => CONV_VER === 'todos' || grupo(f) === CONV_VER).sort(CLAVES[CONV_ORDEN.campo] || CLAVES.estado);
const CLAVES = {carpeta: (a, b) => carpeta(a).localeCompare(carpeta(b), 'es') || rango(a) - rango(b) || bytes(b) - bytes(a), estado: (a, b) => rango(a) - rango(b) || bytes(b) - bytes(a), nombre: (a, b) => nombre(a).localeCompare(nombre(b), 'es'), fecha: (a, b) => ((b.c || {}).fecha || '').localeCompare((a.c || {}).fecha || '') || bytes(b) - bytes(a), tamano: (a, b) => bytes(b) - bytes(a), ahorro: (a, b) => ahorro(b) - ahorro(a)};
const lista = filas.filter(f => (CONV_VER === 'todos' || grupo(f) === CONV_VER) && (!CONV_EXC || (f.c && claveExc(f.c.excluido) === CONV_EXC))).sort(CLAVES[CONV_ORDEN.campo] || CLAVES.carpeta);
if (CONV_ORDEN.inverso) lista.reverse();
const cuenta = {}; filas.forEach(f => cuenta[grupo(f)] = (cuenta[grupo(f)] || 0) + 1);
const E = ESTADO && ESTADO.cola; const corr = ts.find(t => t.estado === 'corriendo');
const E = ESTADO && ESTADO.cola, corr = ts.find(t => t.estado === 'corriendo'), eta = d.cola.eta;
const fmtDe = x => esc(((ESTADO.formatos || {})[x || 'h265_10bit'] || {}).nombre || x || '').split(' · ')[0];
const filaConv = f => {
const primeroPend = (ts.filter(t => t.estado === 'pendiente').sort((a, b) => (b.orden || 0) - (a.orden || 0) || a.id - b.id)[0] || {}).id;
const filaConv = (f, conCarpeta) => {
const {c, t, i} = f;
const motorT = t ? (t.motor === 'resolve' ? 'Resolve' : (t.motor === 'braw' ? 'BRAW · ' : '') + (MOTORES && MOTORES.formatos[t.formato || 'h265_10bit'] === 'gpu' ? 'GPU' : 'CPU')) + ' · ' + fmtDe(t.formato) : c.motor === 'braw' ? 'lector de BRAW' : c.motor === 'resolve' ? 'Resolve' : 'ffmpeg';
const corre = t && t.estado === 'corriendo', pct = corre ? Math.max(0, Math.min(100, Math.round(t.pct || 0))) : 0;
const est = corre ? `<span class="est prog ${pct >= 50 ? 'media' : ''}" style="--p:${pct}%" title="convirtiendo: ${pct} %">${pct} %</span>` : t ? `<span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}</span>` : `<span class="est candidato">${c.excluido ? 'no' : 'candidato'}</span>`;
let sub = c ? `${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${motorT}` : motorT;
if (corre) sub = `<span class="acc">convirtiendo${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''}</span> · ${sub}`;
if (corre) { const a = eta && eta.actual && eta.actual.id === t.id ? eta.actual : null; sub = `<span class="acc">convirtiendo${a && a.dur ? ` · ${tiempoLargo(a.dur - a.resto)} de ${tiempoLargo(a.dur)} · quedan ${tiempoLargo(a.resto_seg)}` : ''}${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''}</span> · ${motorT}`; }
else if (t && t.estado === 'error') sub = `<span class="bad" title="${esc(t.log || '')}">${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}</span>`;
else if (t && t.estado === 'descartado') sub = `<span class="mut">${esc(t.log || '')}</span>`;
else if (t && t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió')) sub = `<span class="mut">${esc(t.log)}</span>`;
else if (t && ['listo', 'verificado', 'reemplazado'].includes(t.estado)) sub = `${esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))} · ${motorT}`;
else if (t && ['listo', 'verificado', 'reemplazado'].includes(t.estado)) sub = `${t.nota ? `nota de calidad <b>${t.nota} / 100</b> · ` : ''}${esc((t.salida || '').split(/[\\/]/).pop())} · ${motorT}`;
else if (c && c.excluido) sub += ' · ' + esc(c.excluido);
if (conCarpeta) sub = `<span class="carp">${esc(carpeta(f) || '(raíz)')}</span> · ` + sub;
const errCls = t && (t.estado === 'error' || t.estado === 'descartado') ? ' err' : '';
const bIn = bytes(f), bOut = t && t.bytes_out ? t.bytes_out : 0, bEst = sale(f);
const tam = bOut ? `${gb(bIn)} → ${gb(bOut)}` : c ? `${gb(c.bytes)} → ≈ ${gb(c.estimado)}` : gb(bIn);
const ah = bIn && bEst ? (bEst <= bIn ? '−' + Math.round((1 - bEst / bIn) * 100) : '+' + Math.round((bEst / bIn - 1) * 100)) + '%' : '';
const acts = t && ['listo', 'verificado', 'reemplazado'].includes(t.estado) ? `<button data-comparar="${t.id}" title="Ver el mismo cuadro en el original y en el convertido">comparar</button>` : t && t.estado === 'pendiente' && t.id !== primeroPend ? `<button data-primero="${t.id}" title="Pasa delante de los que esperan">primero este</button>` : '';
const titulo = c ? `${esc(c.codec)} ${esc(c.perfil)} ${esc(c.pix_fmt)} · ${esc(c.res)} · ${esc(c.fps)} fps · ${c.kbps} kb/s → ≤ ${c.kbps_objetivo} kb/s` : esc(t.ruta || '');
return `<div class="cfila ${c && c.excluido ? 'exc' : ''} ${corre ? 'activa' : ''}">${c ? `<input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} ${c.excluido || c.en_cola ? 'disabled' : ''} aria-label="marcar">` : '<span></span>'}${est}<span class="nom" title="${titulo}"><span class="clip">${esc(nombre(f))}</span><span class="sub2${errCls}">${sub}</span></span><span class="tam">${tam}</span><span class="ah${ah.startsWith('+') ? ' bad' : ''}">${ah}</span></div>`;
return `<div class="cfila ${c && c.excluido ? 'exc' : ''} ${corre ? 'activa' : ''}">${c ? `<input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} ${c.excluido || c.en_cola ? 'disabled' : ''} aria-label="marcar">` : '<span></span>'}${est}<span class="nom" title="${titulo}"><span class="clip">${esc(nombre(f))}</span><span class="sub2${errCls}">${sub}</span></span><span class="tam">${tam}</span><span class="ah${ah.startsWith('+') ? ' bad' : ''}">${ah}</span><span class="acts">${acts}</span></div>`;
};
// ---- el momento: un solo botón grande, el que toca ahora
const sel = CAND.filter(c => c.sel), libres = CAND.filter(c => !c.excluido && !c.en_cola);
const vers = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores'));
const notas = vers.filter(t => t.nota), notaMedia = notas.length ? (notas.reduce((s, t) => s + t.nota, 0) / notas.length).toFixed(0) : '';
let mo = '';
if (d.cola.activa) {
const total = E && E.total ? E.total : (pend + (corr ? 1 : 0)), hechosT = E ? (E.hechos || 0) : 0;
const pctTot = total ? Math.round((hechosT + (corr ? (corr.pct || 0) / 100 : 0)) / total * 100) : 0;
mo = `<div class="momento corre"><div class="l1"><span class="spin"></span><b>Convirtiendo ${Math.min(hechosT + 1, total)} de ${total}</b><span class="mut">${eta && eta.seg ? `quedan unas <b>${tiempoLargo(eta.seg)}</b> · termina hacia las <b>${hora(eta.termina)}</b>${eta.medido ? ` · va a ${eta.vel.toFixed(1)}× tiempo real` : ' · estimado, hasta que acabe el primero'}` : 'calculando cuánto falta…'}</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div><div class="bar grande"><i style="width:${pctTot}%"></i></div><div class="l3 mut">${corr ? `ahora: <b>${esc((corr.ruta || '').split(/[\\/]/).pop())}</b> · ${Math.round(corr.pct || 0)} %${eta && eta.actual ? ` · quedan ${tiempoLargo(eta.actual.resto_seg)} de este clip` : ''} · ${eta ? eta.en_espera : pend} en espera` : 'empezando…'}</div></div>`;
} else if (pend) {
mo = `<div class="momento"><div class="l1"><b>${pend} clip${pend > 1 ? 's' : ''} en espera, con la cola parada</b><span class="mut">lo que ya iba se retoma desde cero</span><span class="sep"></span><button class="p grande" id="moReanudar">Reanudar la cola</button></div></div>`;
} else if (vers.length) {
const bIn = vers.reduce((s, t) => s + (t.bytes_in || 0), 0), bOut = vers.reduce((s, t) => s + (t.bytes_out || 0), 0);
mo = `<div class="momento listo"><div class="l1"><span class="ico">✓</span><span><b>${vers.length} clip${vers.length > 1 ? 's' : ''} convertido${vers.length > 1 ? 's' : ''} y verificado${vers.length > 1 ? 's' : ''}</b> · ${gb(bIn)} → ${gb(bOut)}${notaMedia ? ` · nota media ${notaMedia} / 100` : ''}</span><span class="sep"></span><button class="lnk" id="moComparar">comparar unos cuantos</button><button class="ok grande" id="moReemplazar">Reemplazar ${vers.length > 1 ? 'los ' + vers.length + ' originales' : 'el original'} · libera ${gb(Math.max(0, bIn - bOut))}</button></div><div class="l3 mut">Los originales van a _Para borrar/originales, no se borran, y se deshace desde Historial. Si quieres verlo antes, «comparar» enseña el mismo cuadro en los dos y mide la calidad.</div></div>`;
} else if (sel.length) {
const segs = sel.reduce((s, c) => s + (c.dur || 0) / (c.motor === 'braw' ? 1 : 2.5), 0);
mo = `<div class="momento"><div class="l1"><button class="p grande" id="moConvertir">Convertir ${sel.length} clip${sel.length > 1 ? 's' : ''} · ${gb(sel.reduce((s, c) => s + c.bytes, 0))} → ≈ ${gb(sel.reduce((s, c) => s + c.estimado, 0))}</button><span class="mut">unas ${tiempoLargo(segs)} en la tarjeta · el original no se toca</span><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="moAuto" ${(ESTADO.config.optimizar || {}).auto_reemplazar ? 'checked' : ''}> y reemplazar solo cuando estén verificados</label></div></div>`;
} else if (CAND.length) {
mo = `<div class="momento"><div class="l1"><b>${libres.length ? `${libres.length} clip${libres.length > 1 ? 's' : ''} por convertir, ninguno marcado` : 'Nada por convertir ahora mismo'}</b><span class="mut">${libres.length ? 'marca los que quieras en la lista, o todos los válidos' : 'lo que había ya está convertido o no compensa; con el tiempo saldrá material nuevo'}</span></div></div>`;
} else {
mo = `<div class="momento"><div class="l1"><button class="p grande" id="moBuscar">Buscar clips que se puedan aligerar</button><span class="mut">lee los clips de más de ${+$('#optMeses').value || 0} meses y ${+$('#optMin').value || 0} MB, y calcula cuánto pesarían. No convierte nada todavía.</span></div></div>`;
}
$('#momento').innerHTML = mo;
const liga = (id, a) => { const b = $('#' + id); if (b) b.onclick = a; };
liga('moDetener', () => $('#btnColaToggle').click()); liga('moReanudar', () => $('#btnColaToggle').click());
liga('moReemplazar', () => $('#btnReemplazar').click()); liga('moConvertir', () => $('#btnEncolar').click()); liga('moBuscar', () => $('#btnBuscarClips').click());
liga('moComparar', () => { const v = vers.slice().sort((a, b) => (b.bytes_in || 0) - (a.bytes_in || 0))[0]; if (v) comparar(v.id); });
const ma = $('#moAuto'); if (ma) ma.onchange = seguro(async () => { await api('/api/config', {optimizar: {auto_reemplazar: ma.checked}}); ESTADO.config.optimizar.auto_reemplazar = ma.checked; });
// ---- la lista
let html = '';
if (E && E.activa && E.total) { const pctTot = Math.round(((E.hechos || 0) + ((E.actual && E.actual.pct) || 0) / 100) / E.total * 100); html += `<div class="colabar"><b>Convirtiendo ${Math.min((E.hechos || 0) + 1, E.total)} de ${E.total}</b><span class="b"><i style="width:${pctTot}%"></i></span><b>${pctTot} %</b>${corr ? `<span class="mut">${esc((corr.ruta || '').split(/[\\/]/).pop())} · ${Math.round(corr.pct || 0)} %</span>` : ''}</div>`; }
else if (pend) html += `<div class="colabar" style="background:var(--panel2)"><b>${pend} en espera</b><span class="mut">la cola está parada: pulsa Reanudar</span></div>`;
if (filas.length) {
const marcables = CAND.filter(c => !c.excluido && !c.en_cola);
const VER = [['todos', 'todos', filas.length], ['libres', 'por convertir', cuenta.libres], ['cola', 'en cola', cuenta.cola], ['hechos', 'terminados', cuenta.hechos], ['fallos', 'con fallo', cuenta.fallos], ['no', 'excluidos', cuenta.no]];
const ORD = [['estado', 'estado'], ['nombre', 'nombre'], ['fecha', 'fecha'], ['tamano', 'tamaño'], ['ahorro', 'ahorro']];
const ORD = [['carpeta', 'carpeta'], ['estado', 'estado'], ['nombre', 'nombre'], ['fecha', 'fecha'], ['tamano', 'tamaño'], ['ahorro', 'ahorro']];
html += `<div class="convbar">${CAND.length ? `<label style="display:flex;gap:6px;align-items:center;font-weight:500"><input type="checkbox" id="candTodos" ${marcables.length && CAND.filter(c => c.sel).length === marcables.length ? 'checked' : ''} ${marcables.length ? '' : 'disabled'}> marcar todos los válidos</label>` : ''}<span class="sep"></span><span class="mut">ver</span>${VER.filter(x => x[2]).map(([k, t, n]) => `<button class="lnk ${CONV_VER === k ? 'on' : ''}" data-ver="${k}">${t} · ${n}</button>`).join('')}<span class="mut">ordenar por</span>${ORD.map(([k, t]) => `<button class="lnk ${CONV_ORDEN.campo === k ? 'on' : ''}" data-orden="${k}" title="${CONV_ORDEN.campo === k ? 'otra vez para invertir el orden' : ''}">${t}${CONV_ORDEN.campo === k ? (CONV_ORDEN.inverso ? ' ↑' : ' ↓') : ''}</button>`).join('')}</div>`;
html += lista.slice(0, 800).map(filaConv).join('') + (lista.length > 800 ? `<div class="mut" style="padding:8px 0">… y ${lista.length - 800} más: usa un filtro de «ver»</div>` : '') + (!lista.length ? '<div class="mut" style="padding:8px 0">Nada en este grupo.</div>' : '');
const tope = lista.slice(0, 800);
if (CONV_ORDEN.campo === 'carpeta') {
// por carpetas: la ruta una sola vez, y en cada fila solo el nombre del clip
const gs = []; tope.forEach(f => { const k = carpeta(f); let g = gs.find(x => x.k === k); if (!g) { g = {k, filas: []}; gs.push(g); } g.filas.push(f); });
// primero las carpetas con algo que hacer (la que se convierte, las que esperan, las convertibles, las
// terminadas) y al final las que solo tienen excluidos; a igual cosa, por nombre
const rangoG = g => Math.min(...g.filas.map(rango));
gs.sort((a, b) => (rangoG(a) - rangoG(b)) * (CONV_ORDEN.inverso ? -1 : 1) || a.k.localeCompare(b.k, 'es') * (CONV_ORDEN.inverso ? -1 : 1));
html += gs.map((g, gi) => {
const marc = g.filas.filter(f => f.c && !f.c.excluido && !f.c.en_cola), todos = marc.length && marc.every(f => f.c.sel);
const corre = g.filas.some(f => f.t && f.t.estado === 'corriendo'), enCola = g.filas.filter(f => grupo(f) === 'cola').length, hechos = g.filas.filter(f => grupo(f) === 'hechos').length;
const plegada = CONV_PLEGADAS.has(g.k), bIn = g.filas.reduce((s, f) => s + bytes(f), 0), bOut = g.filas.reduce((s, f) => s + sale(f), 0);
return `<div class="cgrupo ${gi ? '' : 'primero'}"><input type="checkbox" data-grupo="${esc(g.k)}" ${todos ? 'checked' : ''} ${marc.length ? '' : 'disabled'} aria-label="marcar la carpeta"><button class="pl" data-plegar="${esc(g.k)}" aria-label="${plegada ? 'desplegar' : 'plegar'}">${plegada ? '▸' : '▾'}</button><b>${esc((g.k || '').split(/[\\/]/).filter(Boolean).join(' › ') || '(raíz)')}</b><span class="mut">${g.filas.length} clip${g.filas.length > 1 ? 's' : ''} · ${gb(bIn)}${bOut ? ' → ' + (g.filas.some(f => f.t && f.t.bytes_out) ? '' : '≈ ') + gb(bOut) : ''}</span><span class="sep"></span><span class="cab ${corre ? 'corre' : ''}">${corre ? 'convirtiendo' : enCola ? `en cola · ${enCola}` : hechos ? `terminados · ${hechos}` : ''}</span></div>` + (plegada ? '' : g.filas.map(f => filaConv(f, false)).join(''));
}).join('');
} else html += tope.map(f => filaConv(f, true)).join('');
html += (lista.length > 800 ? `<div class="mut" style="padding:8px 0">… y ${lista.length - 800} más: usa un filtro de «ver»</div>` : '') + (!lista.length ? '<div class="mut" style="padding:8px 0">Nada en este grupo.</div>' : '');
// los excluidos, en grupos y con su porqué
const exc = {}; CAND.filter(c => c.excluido).forEach(c => { const k = claveExc(c.excluido); exc[k] = (exc[k] || 0) + 1; });
const ks = Object.keys(exc).sort((a, b) => exc[b] - exc[a]);
if (ks.length) html += `<div class="excl"><b style="color:var(--tx)">Excluidos · ${cuenta.no || 0}</b><span>no se ofrecen, y por qué:</span>${ks.map(k => `<button data-exc="${esc(k)}" class="${CONV_EXC === k ? 'on' : ''} ${k === 'no se pudieron leer' ? 'ojo' : ''}">${exc[k]} ${esc(k)}</button>`).join('')}${CONV_EXC ? '<button class="lnk" data-exc="" style="background:none;border:0">ver todos</button>' : ''}</div>`;
}
$('#convLista').innerHTML = html || '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>';
$$('#convLista input[data-i]').forEach(i => i.onchange = () => { CAND[+i.dataset.i].sel = i.checked; resumenCand(); });
const ct = $('#candTodos'); if (ct) ct.onchange = e => { CAND.forEach(c => c.sel = e.target.checked && !c.excluido && !c.en_cola); cargarCola(); };
$$('#convLista [data-ver]').forEach(b => b.onclick = () => { CONV_VER = b.dataset.ver; cargarCola(); });
$$('#convLista [data-grupo]').forEach(g => g.onchange = e => { CAND.forEach(c => { if (padreDe(c.rel) === g.dataset.grupo && !c.excluido && !c.en_cola) c.sel = e.target.checked; }); cargarCola(); });
$$('#convLista [data-plegar]').forEach(b => b.onclick = () => { const k = b.dataset.plegar; if (CONV_PLEGADAS.has(k)) CONV_PLEGADAS.delete(k); else CONV_PLEGADAS.add(k); cargarCola(); });
$$('#convLista [data-ver]').forEach(b => b.onclick = () => { CONV_VER = b.dataset.ver; if (CONV_VER !== 'no') CONV_EXC = ''; cargarCola(); });
$$('#convLista [data-exc]').forEach(b => b.onclick = () => { CONV_EXC = b.dataset.exc; CONV_VER = CONV_EXC ? 'no' : 'todos'; cargarCola(); });
$$('#convLista [data-orden]').forEach(b => b.onclick = () => { if (CONV_ORDEN.campo === b.dataset.orden) CONV_ORDEN.inverso = !CONV_ORDEN.inverso; else CONV_ORDEN = {campo: b.dataset.orden, inverso: false}; cargarCola(); });
$$('#convLista [data-primero]').forEach(b => b.onclick = () => accion(b, async () => { await api('/api/optimizar/control', {accion: 'primero', id: +b.dataset.primero}); await cargarCola(); }));
$$('#convLista [data-comparar]').forEach(b => b.onclick = () => comparar(+b.dataset.comparar));
}
// ---- comparar: el mismo cuadro en el original y en el convertido, con lupa, y la nota de calidad
function comparar(id){
const t = COLA_TS.find(x => x.id === id); if (!t) return;
const durC = t.dur || 0, nom = (t.ruta || '').split(/[\\/]/).pop();
const puntos = durC > 2 ? [0.1, 0.5, 0.9].map(p => Math.round(durC * p * 10) / 10) : [0];
let tAct = puntos[Math.floor(puntos.length / 2)];
const src = cual => `/api/optimizar/cuadro?id=${id}&cual=${cual}&t=${tAct}`;
const lado = (cual, tit, det) => `<div class="lado"><div style="display:flex;gap:8px;align-items:baseline"><b>${tit}</b><span class="mut" style="font-size:12px">${det}</span></div><div class="marco" data-lado="${cual}"><img src="${src(cual)}" alt="${tit}: cuadro en el segundo ${tAct}"><div class="lupa"></div></div></div>`;
const notaHtml = () => t.nota ? `<b class="n ${t.nota < 93 ? 'baja' : ''}">${t.nota} <span style="font-size:13px;color:var(--mut);font-weight:500">/ 100</span></b><span class="mut">VMAF sobre unos 10 s del medio del clip · el cuadro peor, ${t.nota_min}. De 93 arriba no se nota a simple vista.</span>` : `<button class="p" id="cmpMedir">Medir la calidad</button><span class="mut">compara unos 10 s del medio del clip con el original; tarda entre medio minuto y dos.</span>`;
const fm = (ESTADO.formatos || {})[t.formato || 'h265_10bit'] || {};
modal(`Comparar: ${nom}`, `<div class="cmp">${lado('original', 'Original', `${gb(t.bytes_in)}${t.motor === 'braw' ? ' · BRAW revelado con la misma curva' : ''}`)}${lado('convertido', 'Convertido', `${t.bytes_out ? gb(t.bytes_out) + ' · ' : ''}${esc((fm.nombre || '').split(' · ')[0])}`)}</div>
<div class="cmpt"><span>cuadro en el segundo</span>${puntos.map(p => `<button data-t="${p}" class="${p === tAct ? 'on' : ''}">${p}</button>`).join('')}<span style="margin-left:8px">pasa el ratón por la imagen: la lupa amplía el mismo recorte en los dos</span></div>
<div class="cmpnota"><div><span class="cabsec" style="margin:0">Nota de calidad</span><div id="cmpNota" style="display:flex;flex-direction:column;gap:4px">${notaHtml()}</div></div><div><span class="cabsec" style="margin:0">Se conserva</span><span>${durC ? tiempoLargo(durC) + ' · ' : ''}${t.fps ? t.fps + ' fps · ' : ''}código de tiempo · audio · etiquetas de la cámara · ${fm.nombre && fm.nombre.includes('10') ? '10 bits de color' : 'el color del original'}</span></div><div>${t.estado === 'verificado' && !(t.formato || '').startsWith('prores') ? `<button class="ok" id="cmpReemplazar">Reemplazar este original</button><span class="mut">va a _Para borrar/originales; se deshace desde Historial</span>` : t.estado === 'reemplazado' ? '<span class="mut">El original ya se reemplazó: está en _Para borrar/originales.</span>' : '<span class="mut">Cuando esté verificado se podrá reemplazar desde aquí.</span>'}</div></div>`, null);
$('#modal').classList.add('ancho');
const marcos = $$('#modalCuerpo .marco');
const lupa = (ev) => {
const m = ev.currentTarget, img = m.querySelector('img'), r = img.getBoundingClientRect(), Z = 3;
const rx = Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)), ry = Math.max(0, Math.min(1, (ev.clientY - r.top) / r.height));
marcos.forEach(mm => { const im = mm.querySelector('img'), rr = im.getBoundingClientRect(), l = mm.querySelector('.lupa'); mm.classList.add('con'); l.style.backgroundImage = `url("${im.src}")`; l.style.backgroundSize = `${rr.width * Z}px ${rr.height * Z}px`; l.style.backgroundPosition = `${-(rx * rr.width * Z - 110)}px ${-(ry * rr.height * Z - 110)}px`; l.style.left = `${Math.max(0, Math.min(rr.width - 220, rx * rr.width - 110))}px`; l.style.top = `${Math.max(0, Math.min(rr.height - 220, ry * rr.height - 110))}px`; });
};
marcos.forEach(m => { m.onmousemove = lupa; m.onmouseleave = () => marcos.forEach(mm => mm.classList.remove('con')); const im = m.querySelector('img'); im.onerror = () => { m.innerHTML = `<span style="padding:12px;text-align:center">no se pudo sacar el cuadro ${m.dataset.lado === 'original' ? 'del original' : 'del convertido'}</span>`; }; });
$$('#modalCuerpo [data-t]').forEach(b => b.onclick = () => { tAct = +b.dataset.t; $$('#modalCuerpo [data-t]').forEach(x => x.classList.toggle('on', x === b)); marcos.forEach(m => { const im = m.querySelector('img'); if (im) im.src = src(m.dataset.lado); }); });
const bm = $('#cmpMedir'); if (bm) bm.onclick = () => accion(bm, async () => { $('#cmpNota').innerHTML = '<span class="spin"></span> midiendo…'; try { const r = await api('/api/optimizar/calidad', {id}); t.nota = r.vmaf; t.nota_min = r.minimo; $('#cmpNota').innerHTML = notaHtml(); } catch (e) { $('#cmpNota').innerHTML = `<span class="bad">${esc(e.message)}</span>`; } });
const br = $('#cmpReemplazar'); if (br) br.onclick = () => accion(br, async () => { const r = await api('/api/optimizar/reemplazar', {ids: [id]}); const x = r.resultados[0] || {}; aviso(x.ok ? `Reemplazado: ${x.motivo}` : `No se reemplazó: ${x.motivo}`, x.ok ? 'ok' : 'bad', x.ok ? {texto: 'Ver historial', ir: () => irA('historial')} : null); $('#modal').classList.remove('on'); cargarCola(); });
}
$('#btnColaToggle').onclick = () => accion($('#btnColaToggle'), async () => { await api('/api/optimizar/control', {accion: $('#btnColaToggle').textContent === 'Detener' ? 'detener' : 'iniciar'}); await cargarEstado(); cargarCola(); });
const idsPorEstado = async (...est) => { const d = await api('/api/optimizar/cola'); const raiz = raizActual().toLowerCase(); return d.trabajos.filter(t => est.includes(t.estado) && (t.raiz || '').toLowerCase() === raiz && !(est.includes('verificado') && (t.formato || '').startsWith('prores'))).map(t => t.id); };
@@ -2138,7 +2278,7 @@ const asistente = seguro(async (raiz, escanearAntes) => {
const NOMBRE_PASO = {plan: 'pensando el plan', ordenar: 'ordenando carpetas y archivos', stock: 'apartando stock viejo', duplicados: 'juntando repetidos', analizar: 'leyendo los clips', convertir: 'convirtiendo a 10 bits', respaldo: 'copiando el respaldo', fin: 'listo'};
// ---------------- modal ----------------
function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalSi').disabled = false; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }
function modal(titulo, html, onSi){ $('#modal').classList.remove('ancho'); $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalSi').disabled = false; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }
document.addEventListener('keydown', e => { if (e.key === 'Escape' && $('#modal').classList.contains('on')) $('#modal').classList.remove('on'); });
$('#modal').addEventListener('click', e => { if (e.target === $('#modal')) $('#modal').classList.remove('on'); });