DEV Community

Cover image for Cómo detectar los cortes de escena con ffmpeg y dividir el vídeo justo en ellos

Cómo detectar los cortes de escena con ffmpeg y dividir el vídeo justo en ellos

ffmpeg puede encontrar los cortes de un vídeo editado con un solo filtro, y la mayoría de las guías se quedan ahí: select='gt(scene,0.4)',showinfo, se leen las marcas de tiempo y listo. Con un montaje real quedan dos cosas pendientes. ¿Qué umbral separa los cortes de los movimientos bruscos de cámara, y cómo se convierten las marcas de tiempo en archivos que de verdad empiecen en el corte? El editor de vídeo de la aplicación de escritorio de osFoundry resuelve la primera mitad en Rust (canvas_scene_split en canvas_operations.rs) y se ahorra la segunda por completo. A continuación se reconstruyen las dos mitades en Python y se miden sobre un vídeo, con los números que deciden cada elección.

El archivo de prueba es el resumen del lanzamiento del vuelo de prueba Artemis II que la NASA publicó en dominio público: 92 segundos de H.264 a 640×360, 30 fps, 2.760 fotogramas, montados a partir de 19 planos. Todos los tiempos se midieron en un Apple M1 con ffmpeg 7.1 y Python 3.9.

Detectar los cortes

El comando es el mismo que aparece en todas las guías. El detalle que le cuesta una tarde a más de uno es adónde va la salida: showinfo escribe en el log de ffmpeg, que es stderr. Si se lee stdout, se obtiene una lista vacía de una ejecución que sí ha funcionado.

import re, subprocess

def detect(video, threshold):
    vf = f"select='gt(scene,{threshold})',showinfo"
    p = subprocess.run(["ffmpeg", "-hide_banner", "-i", video, "-an",
                        "-filter_complex", vf, "-f", "null", "-"],
                       capture_output=True, text=True)
    return [round(float(t) * 1000)
            for t in re.findall(r"pts_time:([0-9.]+)", p.stderr)]
Enter fullscreen mode Exit fullscreen mode

Con 0.27, el valor por defecto de la aplicación, devuelve 18 tiempos de corte en milisegundos, de 3.133 a 73.033. La pasada tardó 0,80 s (mediana de 5 ejecuciones), así que la detección es lo bastante barata como para repetirla con otro umbral siempre que haga falta.

Qué mide la puntuación de escena

El umbral es más fácil de elegir cuando se sabe qué es ese número. En libavfilter/f_select.c, get_scene_score calcula la diferencia absoluta media entre el fotograma actual y el anterior (mafd) y le resta la mafd del fotograma anterior:

score = min(mafd, |mafd - prev_mafd|) / 100
Enter fullscreen mode Exit fullscreen mode

Con entrada YUV solo se compara el plano de luma. Eso hace que la puntuación se pueda reconstruir en pocas líneas de Python, que es la forma más rápida de entender un valor inesperado:

def luma(video, n):
    p = subprocess.run(["ffmpeg", "-v", "error", "-i", video,
                        "-vf", f"select='eq(n,{n})'", "-frames:v", "1",
                        "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"],
                       capture_output=True)
    return p.stdout[:640 * 360]   # Y plane only

def mafd(a, b):
    return sum(abs(x - y) for x, y in zip(a, b)) / len(a)

f0, f1, f2 = (luma(VIDEO, k) for k in (n - 2, n - 1, n))
prev, cur = mafd(f0, f1), mafd(f1, f2)
score = min(cur, abs(cur - prev)) / 100
Enter fullscreen mode Exit fullscreen mode

Importa tomar el plano Y de yuv420p en lugar de pedir -pix_fmt gray: la conversión a gris estira el rango de valores y las puntuaciones reconstruidas salían un 16 % más altas. Con el plano Y en bruto, cuatro fotogramas de este vídeo coinciden con las puntuaciones del propio ffmpeg:

fotograma qué ocurre mafd mafd anterior puntuación
33.300 s corte seco al público 57.12 1.31 0.558
33.333 s el fotograma siguiente a ese corte 13.75 57.12 0.138
58.567 s sacudida de cámara en el encendido de los motores 27.03 10.88 0.161
63.667 s corte de la plataforma a un plano general del cielo 34.64 0.90 0.337

Arriba: dos fotogramas consecutivos en el encendido, con puntuación 0.161. Abajo: el corte de la plataforma al plano general, con puntuación 0.337

La resta es lo que evita que los planos en movimiento disparen un corte. El fotograma del encendido difiere del anterior en 27 niveles de brillo de media, el doble que el fotograma posterior al corte al público. Pero el fotograma anterior ya había cambiado 10,9, así que solo cuenta el aumento, y puntúa 0,16. El corte de la plataforma al cielo cambia menos que muchos otros cortes, pero llega después de un fotograma casi quieto, así que puntúa 0,34. Un corte es un salto en el cambio, no simplemente mucho cambio.

Elegir el umbral a partir de los datos

En lugar de adivinar, se vuelca una vez la puntuación de cada fotograma y se mira dónde está el hueco:

ffmpeg -i launch.mp4 -an -vf "select='gte(scene,0)',metadata=print:file=-" -f null - \
  | grep -o 'scene_score=[0-9.]*'
Enter fullscreen mode Exit fullscreen mode

La mediana de los fotogramas de este vídeo puntúa 0,0015. Diecisiete fotogramas superan 0,5, uno puntúa 0,337 y el siguiente más alto es el del encendido, con 0,161. Cualquier umbral entre 0,162 y 0,337 devuelve los mismos 18 cortes. El barrido, con y sin un intervalo mínimo de 500 ms entre cortes:

umbral fotogramas por encima cortes tras el intervalo de 500 ms
0.08 27 20
0.10 24 20
0.12 20 19
0.15 19 19
0.20 18 18
0.27 18 18
0.35 17 17
0.40 17 17
0.50 17 17

Casi todo el rango 0,3–0,5 que sugieren la lista de correo de ffmpeg y muchos tutoriales es demasiado alto para este vídeo: a partir de 0,35, el corte de la plataforma al cielo desaparece. Con 0,15 o menos, el encendido empieza a generar cortes. En material con mucho movimiento rápido la franja segura puede ser estrecha, así que conviene echar un vistazo a la distribución antes de fiarse de un número sacado del vídeo de otra persona.

El intervalo mínimo

Las filas de 0,08 a 0,12 muestran por qué la aplicación descarta los aciertos que llegan demasiado pronto después del anterior (parse_scene_times, 500 ms por defecto). Con 0,12, el corte de 33,300 s vuelve a dispararse en el fotograma siguiente, a 33,333 s, porque ese fotograma todavía difiere de su vecino en 13,75 mientras que el salto previo fue enorme. Con 0,08, el encendido produce ocho aciertos entre 57,7 s y 59,0 s. Un intervalo de medio segundo reduce cada ráfaga a una sola marca:

def min_gap(hits, gap_ms=500):
    out = []
    for ms in hits:
        if not out or ms - out[-1] >= gap_ms:
            out.append(ms)
    return out
Enter fullscreen mode Exit fullscreen mode

No arregla un umbral demasiado bajo, solo los duplicados. Con 0,08 el intervalo sigue dejando dos cortes falsos en el encendido.

Dividir con -c copy no cae en los cortes

El siguiente paso obvio es el muxer segment de ffmpeg con copia de flujo:

ffmpeg -i launch.mp4 -c copy -f segment -segment_times 3.133,6.333,... \
  -reset_timestamps 1 scene%02d.mp4
Enter fullscreen mode Exit fullscreen mode

Tardó 0,05 s y produjo 18 archivos, no 19, todos de entre 3,3 y 6,0 segundos. Las escenas en sí duran de 1,5 a 19,0 segundos. Una copia de flujo solo puede empezar un archivo en un fotograma clave, y esta codificación tiene 18, separados como mucho 6 segundos. ffprobe -skip_frame nokey los lista:

ffprobe -v error -select_streams v -skip_frame nokey \
  -show_entries frame=pts_time -of csv=p=0 launch.mp4
Enter fullscreen mode Exit fullscreen mode

Solo 5 de los 18 cortes coinciden con un fotograma clave, justo donde el codificador original había puesto uno por casualidad. Todos los demás límites de archivo llegan tarde, el peor unos 4 segundos, y cuando dos cortes comparten el mismo fotograma clave siguiente se funden en un solo límite.

Recodificar, y la trampa del medio fotograma

Recodificar forzando un fotograma clave en cada corte permite que los límites caigan en cualquier punto:

ffmpeg -i launch.mp4 -c:v libx264 -crf 18 -preset fast -c:a aac \
  -force_key_frames "$T" -f segment -segment_times "$T" \
  -reset_timestamps 1 scene%02d.mp4
Enter fullscreen mode Exit fullscreen mode

Con $T igual a los tiempos detectados redondeados al milisegundo, esto produjo 19 archivos, y 4 de los 18 cortes seguían llegando un fotograma tarde. Un corte se perdió por completo, uniendo dos escenas en un archivo de 12,3 segundos, y un archivo suelto contenía un único fotograma. La causa es el redondeo. Un corte en el fotograma 269 está en 8,9666… s, y «8.967» queda justo después, así que la comparación elige el fotograma siguiente. Los cuatro cortes tardíos están en tiempos que terminan en un 6 periódico, y lo mismo pasa con el fotograma suelto de 44,067 s.

Adelantar cada tiempo medio fotograma (1/60 s a 30 fps) lo coloca sin ambigüedad antes del fotograma del corte:

T = ",".join(f"{ms / 1000 - 0.5 / 30:.4f}" for ms in cuts)
Enter fullscreen mode Exit fullscreen mode

Con ese cambio, los 18 límites cayeron en el fotograma del corte y las duraciones de los 19 archivos coincidieron con las de las escenas al fotograma. La recodificación tardó 6,9 s para el clip de 92 segundos y escribió 15,2 MB, frente a los 18,2 MB del original, con CRF 18.

O no escribir ningún archivo

La aplicación toma el tercer camino. Sus botones Split at llaman a canvas_split_clip en canvas_layers.rs, que acorta el clip seleccionado e inserta un clip nuevo sobre el mismo recurso con un punto de entrada posterior. No se decodifica ni se escribe nada, así que no hay problema de fotogramas clave ni pérdida por generación, y la codificación ocurre una sola vez, al exportar el montaje terminado. Si el objetivo es volver a montar el vídeo y no entregarle a alguien 19 archivos, esa es la versión que conviene copiar: guardar la lista de cortes como puntos de entrada y salida y dejar que el render final haga el trabajo preciso al fotograma.

El script completo, con todas las mediciones anteriores, ocupa unas 120 líneas de Python con solo la biblioteca estándar, alrededor de ffmpeg y ffprobe. En la aplicación de escritorio de osFoundry, la misma detección es el preset Find the cuts del editor de vídeo, y se ejecuta en tu propio equipo, como se explica en la página sobre el enfoque local. La aplicación es gratuita desde la página de descarga de osFoundry.

Top comments (0)