1.7.0: recorrido paso a paso, sugerencias en Por nombrar, stock por antigüedad, Convertidor con formatos y GPU/CPU, respaldo por lotes, barra de tareas corregida

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-15 20:48:04 -05:00
padre 0895846f34
commit 6bdf6a8457
Se han modificado 4 ficheros con 220 adiciones y 60 borrados

Ver fichero

@@ -164,6 +164,10 @@
.asis-paso h4{margin:0 0 4px;font-size:13.5px}
.asis-paso label{display:block;margin:4px 0;cursor:pointer}
.asis-frase{color:var(--acc)}
.colabar{display:flex;gap:12px;align-items:center;padding:10px 12px;background:var(--acc2);border-radius:6px;margin-bottom:10px;font-size:13px}
.colabar .b{flex:1;height:8px;background:#fff;border-radius:99px;overflow:hidden}
.colabar .b i{display:block;height:100%;background:var(--acc)}
.colabar b{white-space:nowrap}
.est{display:inline-block;padding:1px 8px;border-radius:99px;font-size:11px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
.est.pendiente,.est.cancelado{background:var(--panel2);color:var(--mut)}
.est.corriendo{background:var(--acc2);color:var(--acc)}
@@ -282,7 +286,7 @@
<nav id="nav" aria-label="Vistas">
<a data-t="carpeta" class="on" tabindex="0">Carpeta</a>
<a data-t="revisar" tabindex="0">Revisar y ordenar</a>
<a data-t="convertir" tabindex="0">Convertir a 10 bits</a>
<a data-t="convertir" tabindex="0">Convertidor</a>
<a data-t="respaldar" tabindex="0">Respaldar</a>
<a data-t="historial" tabindex="0">Historial</a>
<a data-t="ajustes" class="eng" tabindex="0" title="Clientes, sus carpetas y plantillas">⚙ Ajustes</a>
@@ -302,7 +306,7 @@
<div><b>1 · Escanear</b>Lee nombres, tamaños y fechas. Solo lectura.</div>
<div><b>2 · Revisar</b>Qué pesa: BRAW, cámara, stock, proxies, basura, repetidos.</div>
<div><b>3 · Ordenar</b>Mueve dentro de la misma carpeta. Lo que sobra va a _Para borrar.</div>
<div><b>4 · Convertir</b>Clips ya entregados a MP4 10 bits, unas 10 veces más ligeros.</div>
<div><b>4 · Convertir</b>Clips ya entregados a un formato más ligero (H.265 10 bits, H.264, ProRes), en cola uno tras otro.</div>
<div><b>5 · Respaldar</b>Copia compacta a otro disco, con índice.</div>
</div>
</div>
@@ -377,7 +381,7 @@
<!-- ============ CONVERTIR ============ -->
<section id="t-convertir">
<div class="cab"><h2 class="v">Convertir a 10 bits</h2><span class="sub">clips de cámara <b id="convCriterio">con más de 3 meses y más de 300 MB</b> · <details class="ajc" id="convAjustes"><summary>cambiar</summary><div class="toolbar">
<div class="cab"><h2 class="v">Convertidor</h2><span class="sub">clips de cámara <b id="convCriterio">con más de 3 meses y más de 300 MB</b> · <details class="ajc" id="convAjustes"><summary>cambiar</summary><div class="toolbar">
<label>con más de <input type="number" id="optMeses" value="3" min="0" style="width:60px"> meses</label>
<label>mínimo <input type="number" id="optMin" value="300" min="0" style="width:70px"> MB</label>
<label>calidad <select id="optPerfil"></select></label>
@@ -385,7 +389,8 @@
<label>BRAW en <select id="optColor"><option value="rec709">Rec.709 (se ve bien)</option><option value="log">Log de cámara (plano, más latitud)</option></select></label>
<label><input type="checkbox" id="optExport"> incluir exportados</label>
</div></details></span></div>
<p class="ayuda">Los clips de proyectos ya entregados pasan a <b>MP4 H.265 de 10 bits</b>, unas 10 veces más ligeros. <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. Convertir va en segundo plano, un clip tras otro; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, 10 bits, duración). Al reemplazar, el original se aparta a <span class="path">_Para borrar/originales</span> y el MP4 queda en su lugar con el mismo nombre más «_10bit». Los BRAW van por DaVinci Resolve Studio (debe estar abierto); el resto por ffmpeg con la GPU. Nada se borra; se deshace desde Historial.</span></p>
<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 van por DaVinci Resolve Studio (debe estar abierto) y solo salen a H.265 10 bits; el resto por ffmpeg, con la GPU cuando la hay. 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 class="toolbar">
<button id="btnBuscarClips" class="p">Buscar clips</button>
@@ -538,6 +543,7 @@ async function cargarEstado(){
sel.innerHTML = ESTADO.raices.map(r => `<option value="${esc(r.clave)}">${esc(r.cliente)}</option>`).join('');
if (antes && ESTADO.raices.some(r => r.clave === antes)) sel.value = antes;
pintarIndice();
const fmt = $('#optFormato'); if (!fmt.options.length && ESTADO.formatos) { fmt.innerHTML = Object.entries(ESTADO.formatos).map(([k, v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); fmt.value = ESTADO.config.optimizar.formato || 'h265_10bit'; pintarFormato(); cargarMotores(); }
const perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); perf.value = ESTADO.config.optimizar.perfil; $('#optMeses').value = ESTADO.config.optimizar.meses_minimos; $('#optMin').value = ESTADO.config.optimizar.min_mb; $('#optChroma').value = ESTADO.config.optimizar.chroma; $('#optColor').value = ESTADO.config.optimizar.color; pintarCriterio(); }
const r = cfgRaiz(); const a = r && r.analisis;
if (a && a.estado === 'corriendo') $('#anaEstado').innerHTML = `<span class="spin"></span> leyendo clips: ${a.hechos} de ${a.total}`;
@@ -561,7 +567,7 @@ function detectarCambios(){
fin('cola', 'Conversión terminada.', {texto: 'Ver', ir: () => irA('convertir')});
fin('asis', 'El asistente terminó.' + (ESTADO.asistente.resultado && ESTADO.asistente.resultado.errores && ESTADO.asistente.resultado.errores.length ? ` ${ESTADO.asistente.resultado.errores.length} avisos.` : ''), {texto: 'Ver historial', ir: () => irA('historial')});
fin('resp', 'Copia de respaldo terminada.', {texto: 'Ver', ir: () => irA('respaldar')});
if (antes.cola !== ahora.cola && vistaActual() === 'convertir') cargarCola();
if ((antes.cola !== ahora.cola || ahora.cola) && vistaActual() === 'convertir' && !document.querySelector('#convLista input:focus')) cargarCola();
}
function huella(E){
const h = {};
@@ -688,7 +694,7 @@ async function cargarCarpeta(){
}
function queSeHizo(lote){
if (!lote) return 'Cambio';
if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por MP4 10 bits';
if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por la versión convertida';
if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo';
if (lote.startsWith('dup-')) return 'Juntó material repetido';
return 'Ordenó carpetas y archivos';
@@ -720,7 +726,7 @@ function pasosDe(C, r){
: !cv.camara_bytes ? {e:'hecho', t:'Convertir', d:'No hay material de cámara', corto: 'nada'}
: listos ? {e:'listo', t:'Convertir', d:`${listos} convertido${listos > 1 ? 's' : ''} por verificar`, corto: `${listos} sin verificar`}
: verif ? {e:'listo', t:'Convertir', d:`${verif} verificado${verif > 1 ? 's' : ''} por reemplazar`, corto: `${verif} por reemplazar`}
: reemp ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya en 10 bits`, corto: `${reemp} hechos`}
: reemp ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya convertido${reemp > 1 ? 's' : ''}`, corto: `${reemp} hechos`}
: {e:'pend', t:'Convertir', d:`${gb(cv.camara_bytes)} de cámara${cv.raw_bytes ? ' · ' + gb(cv.raw_bytes) + ' BRAW' : ''}`, corto: gb(cv.camara_bytes)});
const rp = C.respaldo || {};
P.push(!esc ? {e:'bloq', t:'Respaldar', d:'Después de escanear'} : rp.estado === 'corriendo' ? {e:'curso', t:'Respaldar', d:'Copiando…', corto: 'copiando'}
@@ -739,7 +745,7 @@ function pintarSiguiente(P){
else if (C.plan) { s.t = `Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`; s.p = `Todo se mueve dentro de la misma carpeta y nada se borra. ${C.plan.baja ? C.plan.baja + ' son dudosos y están sin marcar. ' : ''}Revisa la lista y aplica; se puede deshacer desde Historial.`; b('Revisar y aplicar', () => { irA('revisar'); subRev('plan'); }, true); }
else if (P[2].e === 'pend') { s.t = 'Siguiente: ordenar la carpeta'; s.p = 'Calcula qué va a pasar (basura y proxies a _Para borrar, grabaciones por mes y cámara, piezas numeradas) y aplícalo con un clic. O deja que el asistente te pregunte paso a paso.'; b('Calcular qué va a pasar', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }, true); b('Asistente paso a paso', asis); }
else if (P[3].e === 'listo') { const d = P[3].d; s.t = 'Siguiente: ' + (d.includes('verificar') ? 'verificar los clips convertidos' : d.includes('reemplazar') ? 'reemplazar los originales' : 'retomar la conversión'); s.p = d.includes('verificar') ? 'Verificar comprueba códec, 10 bits y duración. Después puedes reemplazar los originales (quedan en _Para borrar/originales).' : d.includes('reemplazar') ? 'Los originales se apartan a _Para borrar/originales y el MP4 10 bits queda en su lugar. Nada se borra.' : 'Hay clips en cola con la conversión parada.'; b('Ir a Convertir', () => irA('convertir'), true); }
else if (P[3].e === 'curso') { s.t = 'Convirtiendo clips a 10 bits'; s.p = 'Sigue en segundo plano. El avance está en la barra de abajo; cuando termine, toca reemplazar.'; b('Ver la cola', () => irA('convertir')); }
else if (P[3].e === 'curso') { s.t = 'Convirtiendo clips'; s.p = 'Sigue en segundo plano. El avance está en la barra de abajo; cuando termine, toca reemplazar.'; b('Ver la cola', () => irA('convertir')); }
else if (P[3].e === 'pend') { const cv = C.convertir; s.t = 'Siguiente: aligerar el material de cámara'; s.p = `Hay ${gb(cv.camara_bytes)} de clips de cámara. Los de proyectos ya entregados pueden pasar a MP4 10 bits, unas 10 veces más ligeros. ${cv.raw_bytes ? 'Los BRAW necesitan DaVinci Resolve Studio abierto. ' : ''}El original no se toca hasta que reemplaces.`; b('Buscar clips a convertir', () => { irA('convertir'); $('#btnBuscarClips').click(); }, true); b('Saltar a respaldar', () => irA('respaldar')); }
else if (P[4].e === 'listo') { s.t = 'Siguiente: copiar el respaldo'; s.p = P[4].d + '. Es incremental: lo que ya esté igual se salta.'; b('Ir a Respaldar', () => irA('respaldar'), true); }
else if (P[4].e === 'curso') { s.t = 'Copiando el respaldo'; s.p = 'Sigue en segundo plano. Puedes detenerlo desde la barra de abajo y retomarlo luego.'; }
@@ -964,15 +970,26 @@ $('#btnBuscarClips').onclick = () => accion($('#btnBuscarClips'), async () => {
await cargarEstado();
} else await cargarCandidatos();
});
let MOTORES = null;
function pintarFormato(){ const f = (ESTADO.formatos || {})[$('#optFormato').value]; $('#fmtQue').textContent = f ? f.que : ''; }
$('#optFormato').onchange = () => { pintarFormato(); if (CAND.length) cargarCandidatos().catch(e => aviso(e.message, 'bad')); };
async function cargarMotores(){
try { MOTORES = await api('/api/optimizar/motores'); } catch (e) { $('#motores').textContent = 'no se pudo detectar GPU/CPU'; return; }
const g = MOTORES.gpu, c = MOTORES.cpu;
const gpuT = g.hevc || g.h264 ? `GPU ${esc(g.nombre || 'NVIDIA')}: ${[g.hevc && 'H.265', g.h264 && 'H.264'].filter(Boolean).join(' y ')} acelerados` : 'Sin GPU NVIDIA: todo por CPU (más lento)';
const cpuT = `CPU: ${[c.prores_ks && 'ProRes', c.libx265 && 'H.265', c.libx264 && 'H.264'].filter(Boolean).join(', ') || 'nada'}`;
$('#motores').innerHTML = MOTORES.ffmpeg ? `${gpuT} · ${cpuT}` : '<span class="bad">ffmpeg no encontrado: no se puede convertir</span>';
$$('#optFormato option').forEach(o => { const m = MOTORES.formatos[o.value]; o.disabled = !m; o.textContent = (ESTADO.formatos[o.value] || {}).nombre + (m === 'gpu' ? ' · GPU' : m === 'cpu' ? ' · CPU' : ' · no disponible'); });
}
async function cargarCandidatos(){
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}`;
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}&formato=${$('#optFormato').value}`;
const d = await api('/api/optimizar/candidatos?' + q); CAND = d.candidatos.map(c => ({...c, sel: !c.excluido && !c.en_cola}));
$('#anaEstado').textContent = '';
if (!CAND.length) aviso('No se encontraron clips con esos criterios. Prueba a cambiar los meses o el tamaño mínimo.', '');
await cargarCola();
}
function resumenCand(){ const s = CAND.filter(c => c.sel); const validos = CAND.filter(c => !c.excluido && !c.en_cola); bloquear('btnEncolar', validos.length ? (s.length ? '' : 'x') : 'x'); $('#btnEncolar').textContent = s.length ? `Convertir ${s.length} marcado${s.length > 1 ? 's' : ''}` : 'Convertir'; }
$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); modal('Convertir a 10 bits', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a MP4 H.265 10 bits, uno tras otro, en segundo plano.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, perfil: $('#optPerfil').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola o su salida ya existe (mismo clip en MP4 y MOV).`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = 'Convertir'; };
$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a <b>${esc(fm.nombre)}</b>, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola, su salida ya existe (mismo clip en MP4 y MOV) o son BRAW con un formato que no sea H.265 10 bits.`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = 'Convertir'; };
const EST_TXT = {pendiente:'en espera', corriendo:'convirtiendo', listo:'convertido', verificado:'verificado', reemplazado:'reemplazado', error:'falló', cancelado:'cancelado'};
async function cargarCola(){
const d = await api('/api/optimizar/cola');
@@ -982,7 +999,7 @@ async function cargarCola(){
ts.sort((x, y) => (orden[x.estado] ?? 9) - (orden[y.estado] ?? 9) || y.id - x.id);
COLA_TS = ts;
const n = k => ts.filter(t => t.estado === k).length;
const pend = n('pendiente'), listos = n('listo'), verif = n('verificado'), err = n('error') + n('cancelado');
const pend = n('pendiente'), listos = n('listo'), verif = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores')).length, err = n('error') + n('cancelado');
const est = $('#colaEstado'); est.className = 'est ' + (d.cola.activa ? 'corriendo' : err ? 'error' : verif ? 'verificado' : 'pendiente'); est.textContent = d.cola.activa ? `convirtiendo${d.cola.mensaje ? ' · ' + d.cola.mensaje : ''}` : pend ? `${pend} en espera, parada` : ts.length ? `${verif} verificados · ${n('reemplazado')} reemplazados${err ? ` · ${err} con error` : ''}` : 'sin actividad';
const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Reanudar';
$('#btnReintentar').style.display = err ? '' : 'none'; $('#btnQuitar').style.display = ts.some(t => ['error','cancelado','reemplazado'].includes(t.estado)) ? '' : 'none'; $('#btnVerificar').style.display = listos ? '' : 'none';
@@ -990,23 +1007,26 @@ 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 ? '−' + gb(hechosB) : `${verif}`, hechosB ? `ya liberados · ${verif} verificados` : 'verificados']].map(([b,s]) => `<div class="card"><b>${b}</b><small>${s}</small></div>`).join('') : '';
$('#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('') : '';
resumenCand();
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 === '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 ? Math.round((1-c.estimado/c.bytes)*100) : 0}%</span></div>`).join('');
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 === '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) {
html += `<div class="cabsec">Conversiones<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' : 'ffmpeg · GPU'}</span></span><span class="sub2">${t.estado === 'error' ? `<span class="bad">${esc((t.log || '').slice(-110))}</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 ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 100) + '%' : ''}</span></div>`).join('') + (ts.length > 120 ? `<div class="mut" style="padding:8px 0">… y ${ts.length - 120} más</div>` : '');
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' : (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' ? `<span class="bad">${esc((t.log || '').slice(-110))}</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>` : '');
}
$('#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(); };
}
$('#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).map(t => t.id); };
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); };
$('#btnVerificar').onclick = () => accion($('#btnVerificar'), async () => { const ids = await idsPorEstado('listo'); const r = await api('/api/optimizar/verificar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} verificados.`, ok === r.resultados.length ? 'ok' : 'bad'); if (ok < r.resultados.length) modal('Verificación', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'FALLA'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); });
$('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `<p><b>${ids.length}</b> originales se apartan a <span class="path">_Para borrar/originales</span> dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.</p><p>Cuando estés seguro, una persona vacía _Para borrar a mano.</p>`, () => accion($('#btnReemplazar'), async () => { const r = await api('/api/optimizar/reemplazar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} reemplazados.`, ok === r.resultados.length ? 'ok' : 'bad', {texto: 'Ver historial', ir: () => irA('historial')}); if (ok < r.resultados.length) modal('Reemplazo', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); })); $('#modalSi').textContent = 'Reemplazar'; });
$('#btnReintentar').onclick = () => accion($('#btnReintentar'), async () => { const ids = await idsPorEstado('error', 'cancelado'); await api('/api/optimizar/control', {accion: 'reintentar', ids}); await api('/api/optimizar/control', {accion: 'iniciar'}); cargarCola(); });