Criando screenshots com múltiplos frames

Já viu uma daquelas imagens contendo o nome do arquivo, características de codecs, resolução e qualidade, e alguns quadros do vídeo? Tipo esse aqui:

imperial-starport-frakfurt
Já viu isso?

Geralmente esse tipo de imagem é construída com o auxílio de algum programinha especialista nisso… Só que dá para fazer algo semelhante usando apenas o ffmpeg, o ImageMagick (com os utilitários convert e montage) e um cadinho de auxílio do GNU bc (calculadora de múltipla precisão do projeto GNU) e do sed.

O primeiro passo é obter as informações do arquivo. Nada mais fácil:

$ ffprobe -hide_banner video.mp4
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'video.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    creation_time   : 2014-07-05T18:16:29.000000Z
    encoder         : Lavf53.32.100
  Duration: 00:01:13.84, start: 0.000000, bitrate: 1450 kb/s
    Stream #0:0(und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1280x720 [SAR 1:1 DAR 16:9], 1317 kb/s, 25 fps, 25 tbr, 25 tbn, 50 tbc (default)
    Metadata:
      creation_time   : 2014-07-05T18:16:29.000000Z
      handler_name    : VideoHandler
    Stream #0:1(und): Audio: ac3 (ac-3 / 0x332D6361), 44100 Hz, stereo, fltp, 128 kb/s (default)
    Metadata:
      creation_time   : 2014-07-05T18:16:29.000000Z
      handler_name    : SoundHandler
    Side data:
      audio service type: main

O único trabalho é separar apenas os dados pertinentes e, neste, ponto, o sed vem em nosso auxílio:

$ ffprobe -hide_banner video.mp4 2>&1 |\
  sed -n '/Video:/s/^.\+Video: \(\w\+\).\+, \(.\+\) \[.\+$/Video: \1 (\2)/p'
$ ffprobe -hide_banner video.mp4 2>&1 |\
  sed -n '/Audio:/s@^.\+Audio: \(\w\+\) [^,]\+\(.\+ kb/s\).\+$@Audio: \1\2@p'

As regular expressions, acima, apenas separam e montam a string que vão aparecer logo abaixo do nome do arquivo, na imagem. Vale reparar que apenas duas linhas têm as substrings “Video:” e “Audio:” (em vermelho). Note que essas regular expressions funcionam muito bem para containers MP4. O ffmpeg não segue um padrão muito bom para mostrar as informações de diversos tipos de vídeos e codecs diferentes, você terá que adaptá-las…

Podemos obter a duração do vídeo, em segundos, para calcular de quantos em quantos frames capturaremos uma imagem assim:

$ ffprobe -v error \
  -select_streams v:0 \
  -show_entries stream=duration \
  -of default=nokey=1:noprint_wrappers=1 \
  video.mp4

Para uma visão completa de todas as variáveis que podem ser usadas com o ffprobe, use a linha de comando abaixo:

$ ffprobe -v error \
  -show_format -show_streams \
  video.mp4
[STREAM]
index=0
codec_name=h264
codec_long_name=H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10
profile=Main
...
[/STREAM]
[STREAM]
index=1
codec_name=ac3
codec_long_name=ATSC A/52A (AC-3)
...
[/STREAM]
[FORMAT]
filename=video.mp4
nb_streams=2
...
[/FORMAT]

Note que existem, pelo menos, duas listas distintas: A do formato do container e a dos streams. Para isolar um único item usa-se a opção -show_entries em conjunto com a formatação de saída (opção -of ou -print_format) e a seleção do stream desejado via -select_streams. A primeira isola o item desejado e a segunda livra-se da formatação adicional indesejada.

Essa técnica pode ser interessante para resolver o problema citado anteriormente, ao tentar obter infos sobre os streams contidos no vídeo…

Neste ponto podemos usar uma variedade de técnicas para obter as imagens do vídeo. Sabendo que queremos 30 imagens, colhidas em toda a extensão do vídeo, poderíamos obter um quadro a cada intervalo regular de $latex duration/30″ segundos. Teríamos que transformar os incrementos desses valores, começando de 0 (zero), no formato hh:mm:ss.ccc, de volta, e obter um frame através das opções -ss e -frames:v, no ffmpeg. A coisa ficaria, em pseudo código, mais ou menos assim:

# Isso é um pseudo-código (não é BASH!).
DURATION_INC=DURATION/30
N=0
while [N < 30]; do
  TIME=DURATION_INC*N
  TIMESTR=time2str(TIME)
  ffmpeg -ss $TIMESTR -i video.mp4 -frames:v 1 frame${N}.png
  $((N++))
done

Felizmente existe um atalho para isso. Não é propriamente exato, é meio que uma gambiarra, mas quebra o galho (novamente, desculpe-me pelas longas linhas):

THUMBS=29 # Nº de frames - 1.
THUMBSIZE="128:72" # Tamanho (w,h) do thumbnail.
DURATION="`ffprobe -v error -select_streams v:0 -show_entries stream=duration -of default=nokey=1:noprint_wrappers=1 video.mp4 2>&1`"
CUSTOM_FPS="`echo "scale=4; $THUMBS / $DURATION" | bc`"

# Obtém as imagens e monta os screenshots
ffmpeg -i video.mp4 \
  -vf "fps=$CUSTOM_FPS,scale=$THUMBSIZE" \
  frames%02d.png

O filtro “fps”, com um valor fracionário, simplesmente pulará os frames intermediários, obtendo apenas os frames que queremos. Note que usei uma precisão maior que 3 dígitos apenas para garantir que a divisão da duração seja um cadinho mais correta… Ainda, como obteremos apenas 30 imagens, os valores precedidos pelo nome “frames” têm apenas 2 dígitos.

Já temos os frames e já temos as informações sobre o arquivo. Para montar a montagem dos screenshots basta montar duas imagens. A primeira com as informações e a segunda com os frames, e depois juntá-las. Para montar a imagem com as informações:

$ convert -background lightgray \
  "label:File: $1\\n$VIDEOINFO\\n$AUDIOINFO" \
  title.png

E, para montar a grade com as imagens:

$ THUMBS_PER_LINE=6 \
  montage -border 1 \
    -bordercolor black \
    -background lightgray \
    frames*.png \
    -geometry +2+2 \
    -tile $THUMBS_PER_LINE \
    montage.png

O utilitário montage, que acompanha o ImageMagick faz a mágica. Ele agrupa as imagens “frames*.png”, colocando-as de 6 em 6 em cada linha (opção -tile) e separando-as por 2 pixels (opção -geometry). Coloquei uma borda de 1 pixel preto em cada thumbnail (opções -border e -bordercolor) e a mesma cor de fundo que usei no título (opção -background).

Agora, só precisamos juntar as duas imagens. Mas há um problema…

Por algum motivo, a primeira imagem é tratada como monocromática pelo ImageMagick (não me perguntem porquê!), daí temos que usar outra gambiarra para fazer a coisa funcionar: Precisamos “colorir” o canvas (X11 Canvas, abaixo – No Windows, provavelmente, terá que usar “canvas:red”) de vermelho e depois juntá-las:

$ convert -background lightgray \
  xc:red title.png montage.png \
  -append +repage \
  screenshots.png

A gambiarra está tanto no uso do canvas avermelhado quando no atributo +repage, que retira o canvas inserido na imagem…

O scripr completo fica assim:

#!/bin/bash

# Apenas verifica se a linha de comando está correta!
if [ $# -ne 1 ]; then
  echo "Usage: thumbs <video>"
  exit 1
fi

THUMBS=29          # nº de frames - 1.

THUMBSIZE="128:72" # Poderíamos calcular isso com base no
                   # aspect ratio do vídeo (exercício!).

THUMBS_PER_LINE=6

# Pega infos do vídeo...
# FIXME: Tenho que refinar isso para vídeos .webm, avi, .mov e outros...
#        Funciona bem com MP4.
#        Provavelmente usando -show_entries resolveria...
VIDEOINFO="`ffprobe -hide_banner "$1" 2>&1 | \
  sed -n '/Video:/s/^.\+Video: \(\w\+\).\+, \(.\+\) \[.\+$/Video: \1 (\2)/p'`"
AUDIOINFO="`ffprobe -hide_banner "$1" 2>&1 | \
  sed -n '/Audio:/s@^.\+Audio: \(\w\+\) [^,]\+\(.\+ kb/s\).\+$@Audio: \1\2@p'`"

# Monta a imagem da barra de título.
convert -background lightgray \
  "label:`echo -e "File: $1\\n$VIDEOINFO\\n$AUDIOINFO\\n"`" \
  title.png

# Calcula a taxa de frames por segundo para obter apenas THUMBS quadros do vídeo.
DURATION="`ffprobe -v error \
             -select_streams v:0 \
             -show_entries stream=duration \
             -of default=nokey=1:noprint_wrappers=1 \
             "$1" 2>&1`"
CUSTOM_FPS="`echo "scale=4; $THUMBS / $DURATION" | bc`"

# Obtém as imagens dos frames já no tamanho dos thumbnails.
ffmpeg -i "$1" \
  -vf "fps=$CUSTOM_FPS,scale=$THUMBSIZE" \
  frames%04d.png

# Monta os screenshots.
montage -border 1 \
  -bordercolor black \
  -background lightgray \
  frames*.png \
  -geometry +2+2 \
  -tile $THUMBS_PER_LINE \
  montage.png

# Não preciso mais dos frames.
rm frames*.png

# Gambiarra: Quando misturamos uma imagem em "grayscale" com uma colorida, o
# resultado será uma imagem em "grayscale". Isso é um "bug" do ImageMagick e
# pode ser resolvido "colorindo" a imagem em "grayscale" (envermelhando-a, por exemplo).
convert -background lightgray \
  xc:red \
  title.png montage.png \
  -append \
  +repage \
  "${1%.*}.png"

# Não preciso mais dos arquivos temporários.
rm title.png montage.png

ATENÇÃO:

Repare que na figura dos screenshots que usei lá em cima. Muitos quadros parecem estar repetidos. Isso acontece porque não houveram mudanças significativas nos quadros capturados. Para evitar isso, poderíamos usar um filtro diferente para capturar os quadros. O filtro select serve para selecionar (o que mais?) quadros de acordo com um critério. A linha de comando abaixo selecionará apenas os quadros que tiveram mudanças acima de 40% do conteúdo.

ffmpeg -i video.mp4 \
  -vf "select=gt(scene\,0.4)" \
  -vsync vfr \
  frame%04d.png

Só que esse filtro, ao descartar os quadros intermediários, repetirá os quadros selecionados tantos quantos forem os frames descartados. E não é isso o que queremos… Para descartar quadros duplicados temos que mudar o esquema de sincronismo de vídeo do ffmpeg através da opção -vsync. Usando o modo vfr os quadros duplicados são descartados de verdade.

Esse método não é muito bom para garantir uma quantidade mínima de quadros. Você pode acabar com menos quadros do que quer ou acabar com muitos quadros para selecionar manualmente, mas ele garante imagens “diferentes”.

ATUALIZAÇÃO:

Dando uma olhada nos filtros do ffmpeg descobri o framestep, que faz exatamente o que queremos. Considere que queremos 30 quadros de um vídeo e temos como saber a quantidade de quadros totais do mesmo. Basta usar o filtro com 1/30 da quantidade total de quadros para obtermos os quadros desejados:

ffmpeg -i video.mp4 \
  -vf framestep=`echo "scale=3; $(ffprobe -v error \
    -select_streams v:0 -show_entries stream=nb_frames \
    -of default=nk=1:nw=1 video.mp4) / 30.0" | bc` \
  frames%02d.png

Na opção -of troquei os atributos do formato default para nk (de nokey) e nw (de noprint_wrappers). Repare também que, com a linha acima, não precisamos calcular a duração e aquele “frames por segundo” fracionário… Só que, dependendo do valor calculado para o filtro, podemos acabar com um quadro a mais ou a menos da amostra. Daí, o a conversão, usando o montage teria que ficar:

montage -border 1 \
  -bordercolor black \
  -background lightgray \
  frames{01..30}.png \
  -geometry +2+2 \
  -tile $THUMBS_PER_LINE \
  montage.png

Note o globbing que usei no nome dos arquivos dos frames!

Talvez você tenha maior precisão obtendo a contagem de frames de uma forma diferente. A entrada nb_read_frames retorna a contagem dos frames lidos do stream (nb_read_frames, que pode ser ligeiramente diferente do que está escrito no header do stream, ou seja, nb_frames), mas precisamos mandar o ffprobe contar os frames, do início ao fim do arquivo:

ffprobe -v error \
  -select_streams v:0 \
  -count_frames
  -show_entries stream=nb_read_frames \
  -of default=nk=1:nw=1 video.mp4

Mas, mesmo assim, vale a pena limitar os nomes dos arquivos como citei acima…

Uma dica para testes é a de que essas opções costumam valer também para o ffplay. Então você pode ver o resultado antes de modificar o script.

Anúncios