ffmpeg trova i tagli di un video montato con un solo filtro, e quasi tutte le guide si fermano lì: select='gt(scene,0.4)',showinfo, si leggono i timestamp e fine. Su un montaggio reale, però, restano due questioni aperte. Quale soglia separa i tagli dai tremolii della camera, e come si trasformano i timestamp in file che inizino davvero sul taglio? L'editor video dell'app desktop di osFoundry risolve la prima metà in Rust (canvas_scene_split in canvas_operations.rs) e la seconda la evita del tutto. Qui ricostruiamo entrambe le metà in Python e le misuriamo su un video, con i numeri che decidono ogni scelta.
Il file di prova è il riepilogo del lancio del volo di prova Artemis II pubblicato dalla NASA in pubblico dominio: 92 secondi di H.264 a 640×360, 30 fps, 2.760 fotogrammi, montati a partire da 19 inquadrature. Tutti i tempi sono misurati su un Apple M1 con ffmpeg 7.1 e Python 3.9.
Rilevare i tagli
Il comando è quello che mostrano tutte le guide. Il dettaglio che fa perdere un pomeriggio è dove finisce l'output: showinfo scrive nel log di ffmpeg, cioè su stderr. Se si legge stdout si ottiene una lista vuota da un'esecuzione che in realtà ha funzionato.
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)]
Con 0.27, il valore predefinito dell'app, restituisce 18 tempi di taglio in millisecondi, da 3.133 a 73.033. La passata ha richiesto 0,80 s (mediana di 5 esecuzioni), quindi il rilevamento costa abbastanza poco da poterlo rifare con un'altra soglia ogni volta che serve.
Cosa misura il punteggio di scena
La soglia è più facile da scegliere quando si sa cos'è quel numero. In libavfilter/f_select.c, get_scene_score calcola la differenza assoluta media tra il fotogramma corrente e il precedente (mafd) e poi sottrae la mafd del fotogramma precedente:
score = min(mafd, |mafd - prev_mafd|) / 100
Con input YUV viene confrontato solo il piano della luma. Per questo il punteggio si ricostruisce in poche righe di Python, che è il modo più rapido per capire un valore inatteso:
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
Conta prendere il piano Y da yuv420p invece di chiedere -pix_fmt gray: la conversione in grigio allarga l'intervallo dei valori e i punteggi ricostruiti uscivano circa il 16 % più alti. Con il piano Y grezzo, quattro fotogrammi di questo video coincidono con i punteggi di ffmpeg:
| fotogramma | cosa succede | mafd | mafd precedente | punteggio |
|---|---|---|---|---|
| 33.300 s | stacco netto sul pubblico | 57.12 | 1.31 | 0.558 |
| 33.333 s | il fotogramma dopo quello stacco | 13.75 | 57.12 | 0.138 |
| 58.567 s | tremolio della camera all'accensione dei motori | 27.03 | 10.88 | 0.161 |
| 63.667 s | taglio dalla rampa a un campo lungo del cielo | 34.64 | 0.90 | 0.337 |
È la sottrazione a impedire che le inquadrature in movimento facciano scattare un taglio. Il fotogramma dell'accensione differisce dal precedente di 27 livelli di luminosità in media, il doppio del fotogramma dopo lo stacco sul pubblico. Ma il fotogramma precedente era già cambiato di 10,9, quindi conta solo l'aumento, e il punteggio è 0,16. Il taglio dalla rampa al cielo cambia meno di molti altri tagli, ma arriva dopo un fotogramma quasi fermo, quindi il punteggio è 0,34. Un taglio è un salto nel cambiamento, non semplicemente tanto cambiamento.
Scegliere la soglia dai dati
Invece di tirare a indovinare, si esporta una volta il punteggio di ogni fotogramma e si guarda dove sta il vuoto:
ffmpeg -i launch.mp4 -an -vf "select='gte(scene,0)',metadata=print:file=-" -f null - \
| grep -o 'scene_score=[0-9.]*'
Il fotogramma mediano di questo video ha punteggio 0,0015. Diciassette fotogrammi superano 0,5, uno vale 0,337 e il successivo più alto è quello dell'accensione, a 0,161. Qualsiasi soglia tra 0,162 e 0,337 restituisce gli stessi 18 tagli. La scansione, con e senza un intervallo minimo di 500 ms tra i tagli:
| soglia | fotogrammi sopra | tagli dopo l'intervallo di 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 |
Gran parte dell'intervallo 0,3–0,5 suggerito dalla mailing list di ffmpeg e da molti tutorial è troppo alto per questo video: da 0,35 in su, il taglio dalla rampa al cielo sparisce. Da 0,15 in giù, l'accensione comincia a produrre tagli. Con riprese piene di movimento rapido la fascia sicura può essere stretta, quindi vale la pena dare un'occhiata alla distribuzione prima di fidarsi di un numero preso dal video di qualcun altro.
L'intervallo minimo
Le righe da 0,08 a 0,12 mostrano perché l'app scarta i rilevamenti che arrivano troppo presto dopo il precedente (parse_scene_times, 500 ms di default). A 0,12, il taglio a 33,300 s scatta di nuovo sul fotogramma successivo a 33,333 s, perché quel fotogramma differisce ancora dal vicino di 13,75 mentre il salto prima era enorme. A 0,08, l'accensione produce otto rilevamenti tra 57,7 s e 59,0 s. Un intervallo di mezzo secondo riduce ogni raffica a un solo marcatore:
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
Non corregge una soglia troppo bassa, solo i duplicati. A 0,08 l'intervallo lascia comunque due tagli falsi nell'accensione.
Dividere con -c copy non cade sui tagli
Il passo successivo più ovvio è il muxer segment di ffmpeg con copia dello stream:
ffmpeg -i launch.mp4 -c copy -f segment -segment_times 3.133,6.333,... \
-reset_timestamps 1 scene%02d.mp4
Ha impiegato 0,05 s e prodotto 18 file, non 19, tutti lunghi tra 3,3 e 6,0 secondi. Le scene vere durano da 1,5 a 19,0 secondi. Una copia dello stream può iniziare un file solo su un keyframe, e questa codifica ne ha 18, distanti al massimo 6 secondi. ffprobe -skip_frame nokey li elenca:
ffprobe -v error -select_streams v -skip_frame nokey \
-show_entries frame=pts_time -of csv=p=0 launch.mp4
Solo 5 dei 18 tagli cadono su un keyframe, quelli dove l'encoder originale ne aveva messo uno per caso. Tutti gli altri confini di file arrivano in ritardo, il peggiore di circa 4 secondi, e quando due tagli condividono lo stesso keyframe successivo si fondono in un unico confine.
Ricodificare, e la trappola del mezzo fotogramma
Ricodificare forzando un keyframe su ogni taglio lascia i confini liberi di cadere ovunque:
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
Con $T impostato sui tempi rilevati arrotondati al millisecondo, sono usciti 19 file, e 4 dei 18 tagli erano ancora in ritardo di un fotogramma. Un taglio è saltato del tutto, unendo due scene in un file di 12,3 secondi, e un file spurio conteneva un solo fotogramma. La causa è l'arrotondamento. Un taglio al fotogramma 269 si trova a 8,9666… s, e «8.967» sta appena dopo, quindi il confronto sceglie il fotogramma successivo. Tutti e quattro i tagli in ritardo cadono su tempi che finiscono con un 6 periodico, e lo stesso vale per il fotogramma spurio a 44,067 s.
Anticipare ogni tempo di mezzo fotogramma (1/60 s a 30 fps) lo mette senza ambiguità prima del fotogramma del taglio:
T = ",".join(f"{ms / 1000 - 0.5 / 30:.4f}" for ms in cuts)
Con questa modifica, tutti i 18 confini sono caduti sul fotogramma del taglio e le durate dei 19 file coincidevano con quelle delle scene al fotogramma. La ricodifica ha richiesto 6,9 s per la clip di 92 secondi e ha scritto 15,2 MB, contro i 18,2 MB del sorgente, a CRF 18.
Oppure non scrivere nessun file
L'app segue la terza strada. I suoi pulsanti Split at chiamano canvas_split_clip in canvas_layers.rs, che accorcia la clip selezionata e inserisce una nuova clip sullo stesso asset con un punto di ingresso successivo. Non viene decodificato né scritto nulla, quindi niente problema di keyframe né perdita di generazione, e la codifica avviene una sola volta, quando si esporta il montaggio finito. Se l'obiettivo è rimontare il video e non consegnare a qualcuno 19 file, è questa la versione da copiare: tenere la lista dei tagli come punti di ingresso e uscita e lasciare che sia il render finale a fare il lavoro preciso al fotogramma.
Lo script completo, con tutte le misure riportate sopra, è di circa 120 righe di Python con la sola libreria standard, attorno a ffmpeg e ffprobe. Nell'app desktop di osFoundry lo stesso rilevamento è il preset Find the cuts dell'editor video, e gira sul tuo computer come descritto nella pagina sull'approccio local-first. L'app è gratuita dalla pagina di download di osFoundry.

Top comments (0)