Удаление плавающей заставки из мультсериала

Волею судеб с некоторых пор я последовательно скачиваю серии мультиков про Наруто. После скачивания для экономии места на сетевом хранилище я пережимаю эти серии в H.265, оставляя только само содержимое, удаляя финальные титры и песню-заставку (opening).

В первой порции этого сериала из 220 серий песня звучала в самом начале, поэтому вырезать её было достаточно просто. Теперь же, скачав пачку новых эпизодов, я обнаружил, что заставка не имеет постоянного времени начала и воспользоваться старым способом уже нельзя — нужно как-то обнаруживать её местоположение в видеофайле.

Ffmpeg справляется и с этой задачей. Механизм заключается в том, что видеофайл прогоняется через фильтр, сравнивающий кадры с образцом на скриншоте, выдавая статистику, из которой можно извлечь нужную метку времени t:.

frame=12549 fps=353 q=-0.0 size=N/A time=00:08:24.36 bitrate=N/A speed=14.2x    
frame=12719 fps=353 q=-0.0 size=N/A time=00:08:31.16 bitrate=N/A speed=14.2x    
[Parsed_blackframe_1 @ 0000023f584beec0] frame:12737 pblack:100 pts:12798 t:511.920000 type:I last_keyframe:12737
[Parsed_blackframe_1 @ 0000023f584beec0] frame:12738 pblack:100 pts:12799 t:511.960000 type:P last_keyframe:12737
...
[Parsed_blackframe_1 @ 0000023f584beec0] frame:12846 pblack:88 pts:12907 t:516.280000 type:P last_keyframe:12845
[Parsed_blackframe_1 @ 0000023f584beec0] frame:12847 pblack:89 pts:12908 t:516.320000 type:P last_keyframe:12845
frame=12891 fps=353 q=-0.0 size=N/A time=00:08:38.08 bitrate=N/A speed=14.2x    
frame=13088 fps=353 q=-0.0 size=N/A time=00:08:45.92 bitrate=N/A speed=14.2x

Это нужно сделать 2 раза: для кадра начала заставки и кадра конца, а затем уже на основании полученных меток времени приступать к обработке и перекодированию. Конечно, операция гораздо более трудоёмкая, чем раньше, но положительные эмоции от найденного работающего решения того стоят, да и надо же чем-то себя занимать на этой бессмысленной новогодней неделе.

Ситуация дополнительно усложнялась следующими факторами:

  1. Кадры заставки от серии к серии могут немного меняться и фильтр перестаёт воспринимать кадр как похожий на образец на скриншоте. Ниже примеры, где один образец кадра наложен на другой и видно, что логотип был сдвинут или увеличен.
naruto_opening5_end.jpg
naruto_opening1_end.jpg
  1. Заставка сама по себе меняется примерно каждые 25 серий, так что тут придётся пробежаться по эпизодам сериала, записать их диапазоны с теми или иными заставками и наделать пары скриншотов для каждого диапазона.
  2. Серии (именно их содержательная часть) длятся неодинаковое время. У одних финальные титры начинаются в 20:06, а у других — на минуту позже.
  3. Наличие квадратных скобок в именах файлов и путях. Почему-то многие владельцы раздач на торрент-трекерах питают слабость к подобным символам, поэтому приходится добавлять в скрипт дополнительные ухищрения.

Чтобы фильтр не был таким дотошным и допускал мелкие изменения в кадре по сравнению с образцом на картинке, надо понизить ему порог чувствительности. Но вскоре выяснилось, что если в образце преобладает чёрный цвет, то фильтр с увеличенной погрешностью начинал ошибочно определять обычные чёрные кадры вместо искомого. Вот пример подобной картинки:

naruto_opening3_start.jpg

В целом получилось так: для полноцветных кадров я выставлял blackframe=80 при стандартном значении 98, а для кадров на чёрном фоне оставлял по умолчанию blackframe.

Чтобы определить длительность серий, я сделал быстрый просмотр, выбирая по 10 серий, чтобы индекс массива для удобства совпадал с последней цифрой серии, и нажимая F8 в Powershell ISE на соответствующей строке.

$episodes = 80..89 |% {$_.ToString("000")}
$e = dir *.avi |? name -match "$($episodes -join '|')"

ffplay -i $e[0].Name -ss 20:06 -an
ffplay -i $e[1].Name -ss 20:06 -an
ffplay -i $e[2].Name -ss 20:06 -an
ffplay -i $e[3].Name -ss 20:06 -an
ffplay -i $e[4].Name -ss 20:06 -an
ffplay -i $e[5].Name -ss 20:06 -an
ffplay -i $e[6].Name -ss 20:06 -an
ffplay -i $e[7].Name -ss 20:06 -an
ffplay -i $e[8].Name -ss 20:06 -an
ffplay -i $e[9].Name -ss 20:06 -an

Результаты я заносил в список прямо в скрипте, подвёрстывая их к диапазонам серий с видами заставок, чтобы потом последовательно раскомментировать нужные строки.

Чтобы в случае чего можно было вернуться к перекодированию без возни с повторным отлавливанием заставки, временные метки записываются в файл CSV:

Name;Start;End
Naruto_Shippuuden_TV2_[001_of_XXX]_[Rus_Jap]_[NIKITOS].avi;511.920000;601.760000
Naruto_Shippuuden_TV2_[002_of_XXX]_[Rus_Jap]_[NIKITOS].avi;119.000000;208.800000
Naruto_Shippuuden_TV2_[003_of_XXX]_[Rus_Jap]_[NIKITOS].avi;130.960000;220.800000
...
Naruto_Shippuuden_TV2_[131_of_XXX]_[Rus_Jap]_[NIKITOS].avi;448.489708;536.827964782715
Naruto_Shippuuden_TV2_[132_of_XXX]_[Rus_Jap]_[NIKITOS].avi;383.299583;471.637840270996
Naruto_Shippuuden_TV2_[133_of_XXX]_[Rus_Jap]_[NIKITOS].avi;1.501500;89.8814591169357

В файле CSV первые конечные метки отличаются от тех, что идут позже — у них меньше десятичных знаков. Дело в том, что сначала я указывал ffmpeg искать в первых 10 минутах видеофайла и начальный, и конечный кадр — в функции была одна команда. Потом я догадался, что конечный кадр ведь идёт после начального, поэтому нужно начинать искать конечный кадр с того места, где был начальный, а так как все заставки идут не более полутора минут, то ими надо поиск и ограничить. Длительность заставок выяснилась путём анализа того же CSV-файла.

Import-Csv 'C:\temp\log.txt' -Delimiter ';' |select *,@{n='Duration';e={$_.end - $_.start}}

Name                                                       Start      End        Duration
----                                                       -----      ---        -------
Naruto_Shippuuden_TV2_[001_of_XXX]_[Rus_Jap]_[NIKITOS].avi 511.920000 601.760000 89,84
Naruto_Shippuuden_TV2_[002_of_XXX]_[Rus_Jap]_[NIKITOS].avi 119.000000 208.800000  89,8
Naruto_Shippuuden_TV2_[003_of_XXX]_[Rus_Jap]_[NIKITOS].avi 130.960000 220.800000 89,84
Naruto_Shippuuden_TV2_[004_of_XXX]_[Rus_Jap]_[NIKITOS].avi 170.000000 259.840000 89,84
Naruto_Shippuuden_TV2_[005_of_XXX]_[Rus_Jap]_[NIKITOS].avi 81.040000  170.760000 89,72
Naruto_Shippuuden_TV2_[006_of_XXX]_[Rus_Jap]_[NIKITOS].avi 191.960000 281.760000  89,8
Naruto_Shippuuden_TV2_[007_of_XXX]_[Rus_Jap]_[NIKITOS].avi 0.000000   89.840000  89,84

Тем самым я убил двух зайцев: уменьшил время обработки и убрал дополнительную функцию, вычисляющую время между найденными кадрами и пишущую в лог ошибку и пропускающую перекодирование, если это время превышало 90 секунд. Но так как значение местонахождения конечного кадра было уже относительным к начальному (например, для начального теперь оно могло быть 150, а для конечного — 88), поэтому для команды кодирования нужно эти значения складывать, и это вычисленное значение даёт большее число знаков после запятой.

Для лучшего ориентирования я добавил вывод информации на экран. Здесь видно, что поиск начала занимает 43 секунды, а поиск конца уже 5.

2024.01.02 17:49:45 Поиск начала заставки в Naruto_Shippuuden_TV2_[147_of_XXX]_[Rus_Jap]_[NIKITOS].avi...
2024.01.02 17:50:28 Начало заставки найдено на 384.509125 сек.
2024.01.02 17:50:28 Поиск конца заставки в Naruto_Shippuuden_TV2_[147_of_XXX]_[Rus_Jap]_[NIKITOS].avi...
2024.01.02 17:50:33 Конец заставки найден на 472.847373962402 сек.

Есть и журнал ошибок. Какие-то файлы всё же приходится обрезать самому в Avidemux, пока компьютер перелопачивает остальные, к тому же, среди набора серий попадается неформат — склеенные или разделённые на несколько частей эпизоды. Но это всё равно лучше, чем обрабатывать руками все 235 видеофайлов.

2024.01.02 14:28:50 Конец заставки не найден в Naruto_Shippuuden_TV2_[125_of_XXX]_[Rus_Jap]_[NIKITOS].avi
2024.01.02 14:29:20 Начало заставки не найдено в Naruto_Shippuuden_TV2_[126_of_XXX]_[Rus_Jap]_[NIKITOS].avi
2024.01.03 07:23:11 Начало заставки не найдено в Naruto_Shippuuden_TV2_[183_of_XXX]_[Rus_Jap]_[NIKITOS].avi

Собственно, скрипт.

## Заставка 1 рассвет
#$episodes = 1..30
## Заставка 2 глаз
#$episodes = 31..53
## Заставка 3 птица
#$episodes = 54..77
#$episodesLong = ,55+71
## Заставка 4 облака
$episodes = 78..102                         # Диапазон обрабатываемых эпизодов
$episodesLong = ,79+80+82+85+91+92+100+102  # Эпизоды с увеличенной длиной
## Заставка 5 водопад
#$episodes = 103..128
#$episodesLong = ,104+106+107+111..115+120+123..125
## Заставка 6 мороженое
#$episodes = 129..153
#$episodesLong = ,131+133+135+138..141+143+145+148+149..155
## Заставка 7 зонтик
#$episodes = 154..179
#$episodesLong = 149..155+157..159+162+167+170..172+174..179
## Заставка 8 лицо
#$episodes = 180..196+199..205
#$episodesLong = 180..184+186..208
## Заставка 9 летающие острова
#$episodes = 206..230
#$episodesLong = ,208+210+211+213..235
## Заставка 10 скала
#$episodes = 231..235
#$episodesLong = 213..235

$inFolder = 'C:\Users\User\downloads\Naruto Shippuuden TV2 `[NIKITOS`] HWP' # Каталог с исходниками
$outFolder = 'C:\temp'             # Каталог с обработанными файлами
$logFolder = 'C:\temp'             # Каталог с логами
$csv = "$logFolder\log.txt"        # Журнал/CSV 
$logError = "$logFolder\error.txt" # Журнал ошибок
$ext = '*.avi'                     # Маска и расширение исходных файлов 
$episodes = $episodes |% {$_.ToString("000")}              # Переделать номера в трёхзначные для поиска файлов
$openingStartPic = Get-Item "$inFolder\opening4_start.jpg" # Картинка начального кадра
$openingEndPic = Get-Item "$inFolder\opening4_end.jpg"     # Картинка конечного кадра
$openingMax = 90          # Длина заставки (сек.), глубина поиска конечного кадра
$vidDuration = 1206       # Стандартная длина эпизода (сек.)
$vidDurationLong = 1266   # Увеличенная длина эпизода (сек.)

# Функция поиска кадров
function Find-Frame ($file,$pic,$startPoint) {
    if ($startPoint) {
        # Конечный кадр
        (& ffmpeg.exe -hwaccel_output_format qsv -ss $startPoint -t $openingMax -an -i $file -loop 1 -i $pic -filter_complex "blend=difference:shortest=1,blackframe=80" -f null - 2>&1) -match 'blackframe.*type:I'
    }
    else {
        # Начальный кадр
        (& ffmpeg.exe -hwaccel_output_format qsv -t 10:00 -an -i $file -loop 1 -i $pic -filter_complex "blend=difference:shortest=1,blackframe=80" -f null - 2>&1) -match 'blackframe.*type:I'
    }
}

# Функция логирования/вывода
function log ($text,$file,$color) {
    $t = (get-date).tostring("yyyy.MM.dd HH:mm:ss")
    if ($file) {
        Tee-Object -InputObject "$t $text" -FilePath $file -Append
    }
    else {
        if (-not $color) {$color = "white"}
        Write-Host -fore $color "$t $text"
    }
}

cd $inFolder

dir $ext |? name -match "$($episodes -join '|')" |% {

# Поиск начала заставки
log -text "Поиск начала заставки в $($_.name)..." -color yellow 
$openingStart = Find-Frame -file $_.name -pic $openingStartPic.fullname
if ($openingStart) {
    $openingStartFrame = ($openingStart[0] -split ' ')[-3] -replace "t:"
    log -text "Начало заставки найдено на $openingStartFrame сек." -color green
}
else {
    log -text "Начало заставки не найдено в $($_.name)" -file $logError
    continue
}

# Поиск конца заставки
log -text "Поиск конца заставки в $($_.name)..." -color yellow
$openingEnd = Find-Frame -file $_.name -pic $openingEndPic.fullname -startPoint $openingStartFrame
if ($openingEnd) {
    $openingEndFrame = [single]$openingStartFrame + [single](($openingEnd[-1] -split ' ')[-3] -replace "t:")
    log -text "Конец заставки найден на $openingEndFrame сек." -color green
}
else {
    log -text "Конец заставки не найден в $($_.name)" -file $logError
    continue
}

# CSV
"$($_.name);$openingStartFrame;$openingEndFrame" |Out-File $csv -Encoding default -Append

# Выбор длины серии в целом
if ($_.basename -match "$($episodesLong -join '|')") {$tail = $vidDurationLong}
else {$tail = $vidDuration}

# Обработка/кодирование
& ffmpeg.exe -y -hide_banner -hwaccel_output_format qsv -i $_.name `
-filter_complex `
"[0:0]trim=start=0:end=$($openingStartFrame),setpts=PTS-STARTPTS[av];
 [0:1]atrim=start=0:end=$($openingStartFrame),asetpts=PTS-STARTPTS[aa];
 [0:0]trim=start=$($openingEndFrame):end=$($tail),setpts=PTS-STARTPTS[bv];
 [0:1]atrim=start=$($openingEndFrame):end=$($tail),asetpts=PTS-STARTPTS[ba];
 [av][bv]concat[outv];[aa][ba]concat=v=0:a=1[outa]" `
-map [outv] -map [outa] -c:v hevc_qsv -global_quality:v 28 `
-c:a libopus -ac 1 -b:a 64k `
"$outFolder\$($_.basename).mp4"

Clear-Variable openingStart,openingEnd,openingStartFrame,openingEndFrame
}

После перекодирования всех серий они стали занимать 9,5 ГБ вместо исходных 67.

С новым годом, желаю вам мира и спокойствия.