1.9.0: convertidor, análisis en paralelo y respaldo

- analizar(): 8 hilos, se puede detener, informa de los ilegibles. ffprobe es
  sobre todo espera de red (0,23 s por clip en el NAS).
- Los formatos pesan de verdad distinto (cq_mas por formato): archivo 14,3 MB,
  ligero 8,2 MB, H.264 18,3 MB sobre el mismo clip.
- ProRes sin -qscale: el perfil vuelve a gobernar la tasa.
- motores(): se prueba el croma 4:2:2 de verdad; si la GPU no puede, va por CPU.
- Audio multipista: -c:a copy solo con una pista AAC (nueva columna pistas_audio).
- Raíces anidadas rechazadas al registrarlas.
- Respaldo: incremental contra el archivo de origen, e índice acumulativo.
- Interfaz: las casillas del plan no repintan la lista ni pierden el foco; el
  buscador de Ajustes conserva lo editado; la lista de stock no salta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 11:08:33 -05:00
padre b492881123
commit 2d7fe55265
Se han modificado 4 ficheros con 208 adiciones y 46 borrados

Ver fichero

@@ -550,7 +550,9 @@ async function cargarEstado(){
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}`;
if (a && a.estado === 'corriendo') $('#anaEstado').innerHTML = `<span class="spin"></span> leyendo clips: ${a.hechos} de ${a.total} <button class="lnk" id="btnAnaDetener">Detener</button>`;
else if (a && a.error && vistaActual() === 'convertir') $('#anaEstado').innerHTML = `<span class="warn">${esc(a.error)}</span>`;
const bad = $('#btnAnaDetener'); if (bad) bad.onclick = () => accion(bad, async () => { await api('/api/optimizar/analizar/detener', {raiz: raizActual()}); });
const h = ESTADO.herramientas || {}; const faltas = [];
if (h.ffmpeg && !h.ffmpeg.ok) faltas.push('ffmpeg no encontrado: sin él no se puede leer ni convertir (pon ffmpeg.exe y ffprobe.exe en la carpeta ffmpeg\\ junto al programa)');
if (h.resolve && !h.resolve.ok) faltas.push('DaVinci Resolve no está instalado: los BRAW no se podrán convertir');
@@ -889,6 +891,20 @@ function fraseDe(r){
if (r.archivos) return `${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''} de ${nom(r.origen_rel)} van a «${esc(r.destino_rel)}»`;
return `${nom(r.origen_rel)} se mueve a «${esc(r.destino_rel)}»`;
}
// actualiza solo las cifras de cada paso y los botones, sin tocar las filas: así el foco se queda
// donde está y se puede recorrer la lista con el teclado
function actualizarContadores(){
if (!PLAN) return;
PASOS_PLAN.forEach(p => {
const caja = $(`#planPasos .paso[data-k="${p.k}"]`); if (!caja) return;
const rs = PLAN.filas.filter(r => (r.paso || 'resto') === p.k), marc = rs.filter(r => r.marcado);
const res = caja.querySelector('.res');
if (res) res.textContent = `${rs.length} cambio${rs.length > 1 ? 's' : ''} · ${gb(rs.reduce((a, r) => a + r.bytes, 0))}${marc.length < rs.length ? ` · ${marc.length} marcado${marc.length !== 1 ? 's' : ''}` : ''}`;
const bt = caja.querySelector('[data-aplicar]');
if (bt) { bt.disabled = !marc.length; bt.textContent = 'Aplicar' + (marc.length ? ' ' + marc.length : ''); }
});
estadoPlan();
}
function pintarPlan(){
if (!PLAN) { estadoPlan(); return; }
const f = ($('#filtroPlan').value || '').toLowerCase();
@@ -912,7 +928,7 @@ function pintarPlan(){
}).join('');
$$('#planPasos [data-ver]').forEach(b => b.onclick = () => { const k = b.dataset.ver; if (PLAN_ABIERTOS.has(k)) PLAN_ABIERTOS.delete(k); else PLAN_ABIERTOS.add(k); pintarPlan(); });
$$('#planPasos [data-aplicar]').forEach(b => b.onclick = () => aplicarPaso(b.dataset.aplicar, b));
$$('#planPasos .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; pintarPlan(); }; });
$$('#planPasos .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; actualizarContadores(); }; });
$$('#planPasos [data-ed]').forEach(b => b.onclick = () => { const r = PLAN.filas.find(x => x.id === +b.dataset.ed); modal('Cambiar destino', `<p>${fraseDe(r)}</p><p class="mut">Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.</p><input type="text" id="mDest" style="width:100%" value="${esc(r.destino_rel)}">`, seguro(async () => { const v = $('#mDest').value.trim(); if (!v) return; r.destino_rel = v; r.editado = true; r.marcado = true; if (!r.archivos) await api('/api/regla', {cliente: PLAN.cliente, origen: r.origen_rel, destino: v, cubeta: r.cubeta}); pintarPlan(); })); });
estadoPlan();
}
@@ -956,7 +972,9 @@ function pintarStock(){
html += `<tr class="stk-grp"><td><input type="checkbox" data-t="${t[0]}" ${todos ? 'checked' : ''} aria-label="marcar tramo"></td><td colspan="2">${t[1]}</td><td class="num">${num(gs.reduce((a, x) => a + x.g.n, 0))}</td><td class="num">${gb(gs.reduce((a, x) => a + x.g.bytes, 0))}</td><td class="mut" style="font-weight:500">${gs.length} carpeta${gs.length > 1 ? 's' : ''}</td></tr>`;
html += gs.map(({g, i}) => `<tr><td><input type="checkbox" data-i="${i}" ${g.sel ? 'checked' : ''} aria-label="marcar"></td><td class="path">${esc(g.carpeta)}</td><td>${esc(g.mes)}</td><td class="num">${g.n}</td><td class="num">${gb(g.bytes)}</td><td class="cell-small">${g.ejemplos.map(esc).join('<br>')}</td></tr>`).join('');
});
const caja = $('#stockCaja'), y = caja ? caja.scrollTop : 0;
$('#tablaStock tbody').innerHTML = html || '<tr><td colspan="6" class="mut">No hay stock de Envato en esta carpeta.</td></tr>';
if (caja) caja.scrollTop = y;
$$('#tablaStock input[data-i]').forEach(i => i.onchange = () => { STOCK[+i.dataset.i].sel = i.checked; pintarStock(); });
$$('#tablaStock input[data-t]').forEach(i => i.onchange = () => { const t = TRAMOS.find(x => x[0] === +i.dataset.t); STOCK.forEach(g => { if (tramoDe(g) === t) g.sel = i.checked; }); pintarStock(); });
resumenStock();
@@ -986,6 +1004,8 @@ async function cargarMotores(){
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>';
const ch = $('#optChroma'); const op422 = ch && ch.querySelector('option[value="422"]');
if (op422) { const hay = MOTORES.chroma422 !== false; op422.disabled = !hay; op422.textContent = '4:2:2 10 bits' + (hay ? (MOTORES.gpu.chroma422 ? ' (pesa más)' : ' (por CPU, más lento)') : ' (este equipo no puede)'); if (!hay && ch.value === '422') ch.value = '420'; }
$$('#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(){
@@ -1117,7 +1137,7 @@ async function guardarClientes(){
await api('/api/clientes', {clientes}); await api('/api/raices', {raices});
AJ = null; await cargarEstado(); pintarAjustes(); aviso('Guardado.', 'ok');
}
$('#filtroCli').oninput = pintarAjustes;
$('#filtroCli').oninput = () => { leerAjustes(); pintarAjustes(); }; // sin leer antes, el filtro borraba lo que estabas editando
$('#btnAddCliente').onclick = () => { leerAjustes(); AJ.clientes.unshift({nombre: '', orig: '', tipo: 'contenido', alias: '', rutas: []}); CLI_ABIERTO = 0; $('#filtroCli').value = ''; pintarAjustes(); const el = $('#clientes .cli.abierto [data-k=nombre]'); if (el) el.focus(); };
$('#btnGuardarPlantillas').onclick = () => accion($('#btnGuardarPlantillas'), async () => { const plantillas = {}; $$('#plantillas input').forEach(i => { plantillas[i.dataset.t] = plantillas[i.dataset.t] || {}; plantillas[i.dataset.t][i.dataset.b] = i.value.trim(); }); await api('/api/plantillas', {plantillas}); await cargarEstado(); PLAN = null; estadoPlan(); aviso('Plantillas guardadas', 'ok'); });