La edición de vídeo basada en texto parece un procesador de textos, pero por debajo es una lista de rangos de tiempo y un único comando de ffmpeg. La transcripción da a cada palabra un tiempo de inicio y otro de fin, las palabras que borras se convierten en cortes y todo lo que queda entre los cortes se conserva. El editor de vídeo de la aplicación de escritorio de osFoundry hace esto en Rust (canvas_transcript.rs). Aquí tienes el pipeline escrito en Python, probado con un clip real de una entrevista de 37,3 segundos, y una comparación con el atajo que parece equivalente pero no lo es: cortar cada trozo con -c copy y unirlos.
Empieza por los tiempos de cada palabra
Sirve cualquier transcriptor que devuelva marcas de tiempo por palabra. Whisper lo hace, y también la mayoría de las APIs de voz a texto en la nube si se lo pides. El clip es una entrevista de la NASA con la astronauta Christina Koch, de 640×360 a 30 fps, y su transcripción tiene 119 palabras:
{"words": [
{"index": 0, "text": "Well", "start_ms": 0, "end_ms": 500},
{"index": 1, "text": "I", "start_ms": 740, "end_ms": 840},
{"index": 2, "text": "think", "start_ms": 840, "end_ms": 1060},
...
], "duration_ms": 37300}
Las palabras se identifican por índice y no por tiempo, porque el índice no cambia mientras el usuario selecciona y deselecciona. La edición de abajo borra las palabras 30 a 81 (una digresión de 16,7 segundos) y 111 a 118 (la despedida).
La aplicación envía al transcriptor solo el audio, y ese paso merece copiarse incluso en un script. Los proveedores cobran por duración, pero no hay motivo para subir la pista de vídeo:
ffmpeg -i clip.mp4 -vn -ac 1 -ar 16000 -f wav clip.wav
Ajusta cada corte a un fotograma
Una palabra que empieza en 9.420 ms empieza en mitad de un fotograma a 30 fps. Si el tiempo del corte no se mueve al límite de un fotograma, el número que ve el usuario y el fotograma donde corta ffmpeg realmente no coinciden. La aplicación redondea al fotograma más cercano en lugar de truncar, y su comentario explica por qué: truncar adelanta todos los cortes, y tras una docena de ediciones se oye cómo se recorta el comienzo de las palabras.
def snap(ms, fps):
frame = 1000 / fps
return round(round(ms / frame) * frame)
Convierte las palabras borradas en rangos que se conservan
Las palabras borradas se convierten en tramos de corte. Los tramos que se solapan o se tocan se fusionan, y lo que queda entre los cortes se conserva, incluidas las pausas entre palabras conservadas. Esta última regla evita que el resultado suene como un montaje automático de saltos: solo desaparece el tiempo que ocupa una palabra borrada, no todos los silencios.
def keep_ranges(words, deleted, duration, fps):
cuts = sorted((snap(w["start_ms"], fps), snap(w["end_ms"], fps))
for w in words if w["index"] in deleted)
merged = []
for s, e in cuts:
if merged and s <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
keeps, cursor = [], 0
for s, e in merged:
if s > cursor:
keeps.append((cursor, s))
cursor = max(cursor, e)
if cursor < duration:
keeps.append((cursor, duration))
return [k for k in keeps if k[1] - k[0] >= 1000 / fps] # a sub-frame keep can't be encoded
Para esta edición devuelve cuatro rangos, no los dos que cabría esperar:
(0, 9433) (16833, 16967) (26133, 34700) (36933, 37300)
El segundo dura 134 ms. Dentro de la digresión borrada, el hablante hace una pausa de 120 ms entre "road" y "but". Ninguna palabra borrada cubre esa pausa, así que la regla "conservar todos los huecos" la conserva, y la salida incluye un destello de cuatro fotogramas del medio de una frase que borraste. El último rango es lo mismo al final: 367 ms de sonido ambiente tras la despedida borrada.
La solución es decidir a qué pertenece cada pausa. Un hueco entre dos palabras borradas, o entre una palabra borrada y el principio o el final del clip, se va con ellas. Un hueco junto a una palabra conservada se queda:
def cut_spans(words, deleted, duration, fps):
spans = []
for i, w in enumerate(words):
if w["index"] not in deleted:
continue
s, e = w["start_ms"], w["end_ms"]
if i == 0 or words[i - 1]["index"] in deleted:
s = 0 if i == 0 else words[i - 1]["end_ms"]
if i == len(words) - 1 or words[i + 1]["index"] in deleted:
e = duration if i == len(words) - 1 else words[i + 1]["start_ms"]
spans.append((snap(s, fps), snap(e, fps)))
return spans
Con cut_spans en lugar de los tramos de palabras, la misma edición da dos rangos, (0, 9433) (26133, 34700), y un resultado de 18,0 segundos.
Recorta vídeo y audio en un solo grafo
Cada rango se convierte en un trim para el vídeo y un atrim para el audio, y un único concat los une todos, vídeo y audio a la vez:
def graph(keeps):
p = []
for i, (s, e) in enumerate(keeps):
p.append(f"[0:v]trim=start={s/1000:.3f}:end={e/1000:.3f},setpts=PTS-STARTPTS[v{i}]")
p.append(f"[0:a]atrim=start={s/1000:.3f}:end={e/1000:.3f},asetpts=PTS-STARTPTS[a{i}]")
p.append("".join(f"[v{i}][a{i}]" for i in range(len(keeps)))
+ f"concat=n={len(keeps)}:v=1:a=1[outv][outa]")
return ";".join(p)
subprocess.run(["ffmpeg", "-y", "-i", "clip.mp4", "-filter_complex", graph(keeps),
"-map", "[outv]", "-map", "[outa]", "out.mp4"], check=True)
Esto recodifica toda la salida, y ese es el coste: de 4,5 a 7,4 segundos para este clip con ffmpeg 7.1 en un Apple M1, según lo que estuviera corriendo a la vez.
Por qué no -c copy
La ruta rápida más obvia es cortar cada rango sin recodificar y unir los trozos con el demuxer concat:
ffmpeg -ss 26.133 -to 34.700 -i clip.mp4 -c copy seg2.mp4
ffmpeg -f concat -safe 0 -i list.txt -c copy out-copy.mp4
Se ejecutaron las dos versiones con los mismos cuatro rangos y se compararon con el original. Los fotogramas de vídeo se emparejaron con el original por hash (la copia conserva los fotogramas originales) y por la miniatura más cercana, y el audio correlacionando ventanas cortas de la salida con el audio original.
| grafo de filtros |
-c copy + concat |
|
|---|---|---|
| duración del vídeo | 18,50 s | 18,87 s |
| la tercera sección empieza en el original en | 26,133 s | 26,000 s |
| duración del audio decodificado | 18,53 s | 20,85 s |
| audio frente a imagen en 7 puntos de control | 0 a 1 ms | cerca de 1 s de retraso tras la segunda unión |
El original tiene un fotograma clave solo cada 8,33 segundos, así que un corte copiado no puede empezar donde dice la transcripción. Cada trozo copiado salió con vídeo y audio de distinta duración (8,73 s frente a 8,58 s en el tercer rango), la unión devolvió cuatro fotogramas de una palabra borrada, y ffmpeg avisó de marcas de tiempo que retroceden al decodificar el audio unido. Además, las coincidencias del audio copiado fueron débiles (correlación de 0,2 a 0,8, frente a 1,00 con el grafo de filtros), así que "cerca de un segundo" es aproximado. Lo que está claro es que no estaba sincronizado, y el espectador vería labios que no coinciden con las palabras.
La copia de flujo va bien para un solo corte en un fotograma clave. Para cortes colocados según una transcripción, recodificar es el precio de que los cortes caigan donde están las palabras.
Todo junto
Las piezas de arriba suman menos de 60 líneas de Python, con solo la biblioteca estándar y un binario de ffmpeg. El editor de vídeo canvas de osFoundry pone este tipo de pipeline detrás de un panel de transcripción: arrastras sobre las palabras, pulsas Suprimir y aplicas los cortes. La aplicación es gratuita desde la página de descarga de osFoundry.
Top comments (0)