From d92814d16cb6dfd7231f3570ddebdadb6dd26ce9 Mon Sep 17 00:00:00 2001 From: Labstream Date: Sat, 10 Oct 2026 16:08:39 -0500 Subject: [PATCH] Convertir: una sola lista con el estado vivo, botones para ver y ordenar, y la barra en la casilla del clip que va MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 18 ++++++++++-- static/index.html | 73 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e310e3e..73e815b 100644 --- a/README.md +++ b/README.md @@ -908,9 +908,21 @@ párrafo cada una, 46 filas con casilla y dos líneas de motivo por fila, y la m - Cuando lo seguro ya está hecho (el caso de Diana Méndez al escribir esto: 43 cosméticos y 5 dudosos), la frase de arriba lo dice así, en vez de un botón «Aplicar» apagado sin explicación. -También del 10 de octubre: en «Añadir carpeta», la clase de trabajo se elige entre cuatro tarjetas (título, -cómo se ordena y «como David Reyes, Danney Gómez y 3 más», los clientes que ya van así) en vez de un -desplegable, y el ejemplo de cómo queda es un árbol de carpetas, no una lista de rutas completas. +También del 10 de octubre: + +- **El menú lateral se queda fijo al bajar.** Munka lo vio irse con el resto de la página (en la 3.3.0 y la + 3.4.0): el cuerpo tenía `height:100%`, el alto de la ventana, así que su rejilla medía una pantalla y el + lateral «sticky» no tenía recorrido. Ahora el cuerpo crece con el contenido y el lateral no se mueve. +- **Convertir, una sola lista.** Antes había dos: los candidatos (con el estado que tenían al pulsar «Buscar», + que se quedaba en «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 está convirtiendo lleva la barra de avance en su + propia casilla de estado («62 %», la fila resaltada) y debajo qué hace el lector. Arriba: «ver» (todos, por + convertir, en cola, terminados, con fallo, excluidos, con su cuenta) y «ordenar por» (estado, nombre, fecha, + tamaño, ahorro; otra vez invierte). Lo convertido muestra el peso real (16,5 GB → 214 MB, −99 %) en vez del + estimado. +- En «Añadir carpeta», la clase de trabajo se elige entre cuatro tarjetas (título, cómo se ordena y «como + David Reyes, Danney Gómez y 3 más», los clientes que ya van así) en vez de un desplegable, y el ejemplo de + cómo queda es un árbol de carpetas, no una lista de rutas completas. Pruebas: `prueba_funcional.py` comprueba además que el avance acaba en «tantas de tantas» con los movimientos de la respuesta; `probar.py` 19, `prueba_orden.py` 33, `prueba_funcional.py` 30 y `prueba_ventana.py` 18. diff --git a/static/index.html b/static/index.html index aafab13..2e49f03 100644 --- a/static/index.html +++ b/static/index.html @@ -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 @@
Buscar clips
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. 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.
Formato y calidad
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.
-
Convertir
Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. 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.
+
Convertir
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). 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.
Verificar
Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.
Reemplazar
El convertido ocupa el sitio del original, con su misma fecha, y el original se aparta a _Para borrar\originales.
@@ -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]) => `
${b}${s}
`).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 ? `${pct} %` : t ? `${EST_TXT[t.estado] || esc(t.estado)}` : `${c.excluido ? 'no' : 'candidato'}`; + let sub = c ? `${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${motorT}` : motorT; + if (corre) sub = `convirtiendo${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''} · ${sub}`; + else if (t && t.estado === 'error') sub = `${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}`; + else if (t && t.estado === 'descartado') sub = `${esc(t.log || '')}`; + else if (t && t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió')) sub = `${esc(t.log)}`; + 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 `
${c ? `` : ''}${est}${esc(nombre(f))}${sub}${tam}${ah}
`; + }; let html = ''; - if (CAND.length) { - html += `
${CAND.length} candidatos
`; - html += CAND.slice(0, 800).map((c, i) => `
${c.en_cola ? 'en cola' : c.excluido ? 'no' : 'candidato'}${esc(c.rel)}${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) : ''}${gb(c.bytes)} → ≈ ${gb(c.estimado)}${c.bytes ? (c.estimado <= c.bytes ? '−' + Math.round((1-c.estimado/c.bytes)*100) : '+' + Math.round((c.estimado/c.bytes-1)*100)) : '0'}%
`).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 += `
Convirtiendo ${Math.min((E.hechos || 0) + 1, E.total)} de ${E.total}${pctTot} %${corr ? `${esc((corr.ruta || '').split(/[\\/]/).pop())} · ${Math.round(corr.pct || 0)} %` : ''}
`; } - else if (pend) html += `
${pend} en esperala cola está parada: pulsa Reanudar
`; - html += `
Cola de conversión${ts.length}
`; - html += ts.slice(0, 120).map(t => `
${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}${esc((t.ruta || '').split(/[\\/]/).pop())} · ${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]}${t.estado === 'descartado' ? `${esc(t.log || '')}` : t.estado === 'error' ? `${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}` : t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió') ? `${esc(t.log)}` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}${t.estado === 'corriendo' ? `
` : ''}
${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}${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)) + '%' : ''}
`).join('') + (ts.length > 120 ? `
… y ${ts.length - 120} más
` : ''); + if (E && E.activa && E.total) { const pctTot = Math.round(((E.hechos || 0) + ((E.actual && E.actual.pct) || 0) / 100) / E.total * 100); html += `
Convirtiendo ${Math.min((E.hechos || 0) + 1, E.total)} de ${E.total}${pctTot} %${corr ? `${esc((corr.ruta || '').split(/[\\/]/).pop())} · ${Math.round(corr.pct || 0)} %` : ''}
`; } + else if (pend) html += `
${pend} en esperala cola está parada: pulsa Reanudar
`; + 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 += `
${CAND.length ? `` : ''}ver${VER.filter(x => x[2]).map(([k, t, n]) => ``).join('')}ordenar por${ORD.map(([k, t]) => ``).join('')}
`; + html += lista.slice(0, 800).map(filaConv).join('') + (lista.length > 800 ? `
… y ${lista.length - 800} más: usa un filtro de «ver»
` : '') + (!lista.length ? '
Nada en este grupo.
' : ''); } $('#convLista').innerHTML = html || 'Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.'; $$('#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); };