Convertir: una sola lista con el estado vivo, botones para ver y ordenar, y la barra en la casilla del clip que va

Antes había dos listas: los candidatos con el estado que tenían al pulsar «Buscar» (se quedaba
«corriendo» en un clip ya acabado) y, 700 filas más abajo, la cola con el avance. Ahora cada clip
sale una vez con su estado de verdad; el que se convierte lleva la barra en su casilla y la fila
resaltada. Arriba: ver (todos, por convertir, en cola, terminados, con fallo, excluidos) y ordenar
por (estado, nombre, fecha, tamaño, ahorro; otra vez invierte). Lo convertido muestra el peso real.

Pruebas: probar 19, prueba_ventana 18.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-10 16:08:39 -05:00
padre eca2ce4adf
commit d92814d16c
Se han modificado 2 ficheros con 76 adiciones y 15 borrados

Ver fichero

@@ -355,6 +355,14 @@
.cfila .ah{font-variant-numeric:tabular-nums;text-align:right;font-weight:600;color:var(--ok);white-space:nowrap}
.cfila .bar{margin-top:4px;height:5px}
.cfila.exc{opacity:.55}
.cfila.activa{background:var(--acc2);margin:0 -14px;padding-left:14px;padding-right:14px;border-radius:6px;border-bottom:0}
.cfila .est.prog{display:block;text-align:center;padding:3px 0;border-radius:5px;background:linear-gradient(90deg,var(--acc) var(--p,0%),#fff var(--p,0%));border:1px solid var(--acc);color:var(--acc);font-variant-numeric:tabular-nums}
.cfila .est.prog.media{color:#fff;text-shadow:0 0 3px rgba(0,0,0,.4)}
.cfila .nom .sub2 .acc{color:var(--acc);font-weight:600}
.convbar{display:flex;gap:2px;align-items:center;flex-wrap:wrap;padding:6px 0 8px;border-bottom:1px solid var(--line);margin-bottom:4px;font-size:12px}
.convbar .mut{margin:0 4px 0 10px}
.convbar .lnk{padding:2px 6px;font-size:12px}
.convbar .lnk.on{background:var(--acc2);border-radius:4px;text-decoration:none}
.cabsec{display:flex;gap:10px;align-items:center;margin:14px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);font-weight:600}
.cabsec .sep{flex:1;height:1px;background:var(--line)}
details.ajc{display:inline}
@@ -676,7 +684,7 @@
<dl>
<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. <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. 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>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>
@@ -1555,7 +1563,7 @@ $('#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 = [];
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'estado', inverso: false};
$('#btnBuscarClips').onclick = () => accion($('#btnBuscarClips'), async () => {
const cv = (CARPETA && CARPETA.convertir) || {};
if (cv.sin_analizar > 0 || (cv.analisis && cv.analisis.estado === 'error')) {
@@ -1607,22 +1615,63 @@ async function cargarCola(){
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('') : '';
// 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.
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; });
const bloquea = t => t && ['pendiente', 'corriendo', 'listo', 'verificado'].includes(t.estado) && (t.formato || 'h265_10bit') === fmt;
const filas = CAND.map((c, i) => { const t = porRuta[(c.ruta || '').toLowerCase()] || null; c.en_cola = bloquea(t) ? t.estado : ''; if (c.en_cola) c.sel = false; return {c, i, t}; });
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 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);
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 fmtDe = x => esc(((ESTADO.formatos || {})[x || 'h265_10bit'] || {}).nombre || x || '').split(' · ')[0];
const filaConv = f => {
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}`;
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 (c && c.excluido) sub += ' · ' + esc(c.excluido);
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 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>`;
};
let html = '';
if (CAND.length) {
html += `<div class="cabsec"><label style="display:flex;gap:6px;align-items:center;text-transform:none;letter-spacing:0;font-weight:500"><input type="checkbox" id="candTodos" ${validos.length && CAND.filter(c => c.sel).length === validos.filter(c => !c.en_cola).length ? 'checked' : ''}> marcar todos los válidos</label><span class="sep"></span>${CAND.length} candidatos</div>`;
html += CAND.slice(0, 800).map((c, i) => `<div class="cfila ${c.excluido ? 'exc' : ''}"><input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} ${c.excluido || c.en_cola ? 'disabled' : ''} aria-label="marcar"><span class="est candidato">${c.en_cola ? 'en cola' : c.excluido ? 'no' : 'candidato'}</span><span class="nom" title="${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"><span class="clip">${esc(c.rel)}</span><span class="sub2">${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${c.motor === 'braw' ? 'lector de BRAW' : c.motor === 'resolve' ? 'Resolve' : 'ffmpeg'}${c.excluido || c.en_cola ? ' · ' + esc(c.excluido || c.en_cola) : ''}</span></span><span class="tam">${gb(c.bytes)} → ≈ ${gb(c.estimado)}</span><span class="ah">${c.bytes ? (c.estimado <= c.bytes ? '−' + Math.round((1-c.estimado/c.bytes)*100) : '+' + Math.round((c.estimado/c.bytes-1)*100)) : '0'}%</span></div>`).join('');
}
if (ts.length) {
const E = ESTADO && ESTADO.cola; const corr = ts.find(t => t.estado === 'corriendo');
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>`;
html += `<div class="cabsec">Cola de conversión<span class="sep"></span>${ts.length}</div>`;
html += ts.slice(0, 120).map(t => `<div class="cfila"><span></span><span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}</span><span class="nom" title="${esc(t.ruta)}"><span class="clip">${esc((t.ruta || '').split(/[\\/]/).pop())} <span class="mut" style="font-weight:500;font-size:11px">· ${t.motor === 'resolve' ? 'Resolve' : (t.motor === 'braw' ? 'BRAW · ' : '') + (MOTORES && MOTORES.formatos[t.formato || 'h265_10bit'] === 'gpu' ? 'GPU' : 'CPU')} · ${esc(((ESTADO.formatos || {})[t.formato || 'h265_10bit'] || {}).nombre || t.formato || '').split(' · ')[0]}</span></span><span class="sub2${t.estado === 'error' || t.estado === 'descartado' ? ' err' : ''}">${t.estado === 'descartado' ? `<span class="mut">${esc(t.log || '')}</span>` : t.estado === 'error' ? `<span class="bad" title="${esc(t.log || '')}">${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}</span>` : t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió') ? `<span class="mut">${esc(t.log)}</span>` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}</span>${t.estado === 'corriendo' ? `<div class="bar"><i style="width:${Math.round(t.pct || 0)}%"></i></div>` : ''}</span><span class="tam">${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}</span><span class="ah">${t.bytes_out ? (t.bytes_out <= t.bytes_in ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 100) : '+' + Math.round((t.bytes_out / t.bytes_in - 1) * 100)) + '%' : ''}</span></div>`).join('') + (ts.length > 120 ? `<div class="mut" style="padding:8px 0">… y ${ts.length - 120} más</div>` : '');
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']];
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>' : '');
}
$('#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-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(); });
}
$('#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); };