"Реклама — двигатель прогресса" — эта легкая фраза, сказанная невзначай моей сестрой, описывает практически весь путь разработки простенького скрипта, который со временем вырос в небольшое клиент-серверное приложение. Итак, в данной статье я расскажу про: авторизацию на youtube с помощью perl, сложные приёмчики с ffmpeg, мимоходом пройдусь по json и sqlite, и покажу, чего стоят подборки видео на youtube.
С чего всё началось
Идея родилась достаточно просто. Просматривая как-то вечером на youtube очередную подборку прикольных видео, я поймал себя на раздражающей мысли, что не хочу смотреть рекламу, а еще — не хочу видеть одно и то же видео дважды. Эта мысль развилась в идею о том, что, вероятно, множество процессов создания подобных видео можно автоматизировать. Прикинув свои возможности, я понял, что мне вполне по силам накидать небольшой скрипт, который меня освободит от рекламы и баянов.
Disclaimer: я не программист, а инженер-микроэлектронщик, так что при оценке кода делайте скидку на этот момент.
Получение данных
У меня было на выбор два источника видео: coub.com и vine.co. Просмотрев контент с обоих сайтов, был сделан выбор в пользу coub.com, что было активно поддержано моей девушкой.
У coub.com относительно недавно появился API, который позволяет тягать с него много всяких данных. Надо сказать, что я не сразу подумал о возможности авторизации на этом сайте, ведь доступ к ендпойнтам открыт для всех желающих. А вот когда авторизовался, то понял, что делать этого не надо было: для авторизованных пользователей открывается куча контента NSFW(not safe for work, 18+), который, вообще говоря, не понятно что делает на этом сайте. Итак, работаем без авторизации.
Пример эндпойнта:
http://coub.com/api/v2/timeline/hot?page=${page_number}&per_page=${per_page}&order_by=newest_popular
Не буду приводить тут код функции, которая тягает с означенного эндпойнта JSON, так как они тривиальна и не интересна.
Работа с данными
Сначала я просто банально смотрел лидеров по количеству просмотров, потом пытался написать хитрые метрики для определения популярности видео, но все это не работало, как хотелось. Попробовал поиграть с кластеризацией, но тоже требуемого эффекта не получил.
В итоге я решил, что надо отслеживать динамику процесса, а для этого написал маленькую базу sqlite на две таблички, которая позволяет мне отслеживать просмотры по различным видео. Все манипуляции с базой лежат на плечах скрипта, который тягает JSON'ы с эндпойнтов, занимается разбором полученных данных и прочее. Также этот скрипт генерирует красивые картинки для понимания динамики процесса и создает JSON с конечными данными для последующего использования. Запускается скрипт раз в полчаса по cron'у.
На картинке хорошо виден набор просмотров днем и ночью, а также моменты публикации ссылок на видео на популярных порталах (ну или включения ботов накрутки просмотров, хе-хе). Время на графике — UTC, картинка кликабельна.
Для работы в perl со всем этим хозяйством мне потребовались следующие модули:
use LWP::Simple;
use JSON::XS qw( decode_json );
use Time::Local;
use DBI;
use Chart::Gnuplot;
Надо отметить, что для работы с sqlite в дистрибутиве должен быть установлен DBD::Sqlite.
Формирование видео
Для формирования красивого видеоряда требуется некоторое время освоиться с одной замечательной утилитой — ffmpeg. Но когда вы научитесь ей пользоваться, возвращаться ко всяким avidemux'ам не захочется. Итак, какие полезные приемы я выучил за время написания скрипта? Начнем с простого.
Подготовка музыки
$local_batch = "$converter -t $audio_dur -i $music_source -af "afade=t=out:st=$start_t_plus:d=$diff,afade=t=in:ss=0:d=$diff,volume=$volume_scale" $res_dir/starter.mp3 -y";
system( $local_batch );
Данная команда отрезает от $music_source кусочек длиной $audio_dur с применением фильтров afade и volume, и сохраняет это в starter.mp3. Фильтр afade позволяет получить эффект повышения(fade-in) и понижения(fade-out) громкости, а volume изменяет громкость всей дорожки целиком.
Превращаем картинку в видео со звуком
$local_batch = "$converter -loop 1 -i $picture_source -i $res_dir/end.mp3 -c:v libx264 -t $end_t $res_dir/ending.mkv -y";
system( $local_batch );
Решаем проблему кривого разрешения
$local_batch = "$converter -i ./video_source/source-video-$i.mp4 ";
$local_batch .= "-filter_complex "";
$local_batch .= "[0]scale=iw*$scale:ih*$scale [sharp]; ";
$local_batch .= "[0]scale=trunc(iw*$blur_scale/2)*2:trunc(ih*$blur_scale/2)*2,crop=$max_w:$max_h,boxblur=30 [blur]; ";
$local_batch .= "[blur][sharp] overlay=(main_w-overlay_w)/2:(main_h-overlay_h)/2" ";
$local_batch .= "-q:v 0 -vb 20M ./video_source/source-video-$i.mpg -y";
system( $local_batch );
Вы много раз видели вертикальное видео с красиво размытым фоном, сделанным из этого же видео. Теперь вы знаете, как это сделать :)
Итак, что же за магия здесь происходит? На вход мы подаем наш ролик и включаем --filter_complex. Дальше мы берем это же видео и приводим к требуемому размеру с заранее рассчитанными коэффициентами и сохраняем его как [sharp]. Потом опять же входное видео приводим к размеру несколько больше требуемого, потом обрезаем его до требуемого размера и применяем размытие, сохраняем как [blur]. Финальный шаг — размещаем видео [sharp] поверх [blur] строго по центру — готово!
Зачем нужна возня с trunc? Дело в том, что ffmpeg не умеет отрезать от видео один пиксель, поэтому где-то вам придется привести размер видео к четному. Где вы это будете делать — на свое усмотрение.
Тёмная магия
Даже не столько магия, сколько способ сложно сделать простой эффект на видео. Требовалось сделать:
- Оверлейный полупрозрачный бокс с названием с fade-out в альфа канал. (Иначе говоря, плавно пропадающее вместе с боксом название)
- Fade-in, fade-out на видео дорожку, переход в белый цвет.
Я не ручаюсь, что этот способ оптимальный, но я нашел только этот.
my $opacity = '@0.4';
$local_batch = "$converter -i ./video_source/video-$i.mpg -i ./audio_misc/cut-audio-$i.mp3 ";
$local_batch .= " -vf "drawbox=enable='between(t,0,$title_dur)':y=(ih/1.3):color=black$opacity:width=iw:height=100:t=max, ";
$local_batch .= " drawtext=enable='between(t,0,$title_dur)':fontfile=$font:text='$title[$i]':fontcolor=white:fontsize=50:x=(w-tw)/2:y=(h/1.3)+30, format=yuv444p "";
$local_batch .= " -codec:a copy -q:v 0 -vb 20M ./video_music/inter$i.mpg -y";
system( $local_batch );
$local_batch = "$converter -i ./video_source/video-$i.mpg -i ./video_music/inter$i.mpg -filter_complex "";
$local_batch .= "[1]fade=out:st=$title_subt:d=$title_fade:alpha=1 [ovr]; ";
$local_batch .= "[0][ovr] overlay=0:0:repeatlast=0, fade=in:st=0:d=$diff:color=white, fade=out:st=$video_duration_diff:d=$diff:color=white" ";
$local_batch .= "-codec:a copy -q:v 0 -vb 20M ./video_music/faded_inter$i.mpg -y";
system( $local_batch );
Разберем подробно, что здесь происходит. В первой части мы добавляем на видео в отведенные временные рамки полупрозрачный черный бокс, а поверх него — белый текст.
Во второй части, используя уже знакомый --filter_complex мы берем видео с боксом и надписью и используем на нем fade-out в альфа канал. Затем берем видео без бокса и надписи и накладываем поверх него [ovr], одновременно применяя к полученному результату fade-in, fade-out видео канала с переходом в белый цвет.
Склеивая вряд полученные таким образом видео, получается единый видеоряд с плавным переходом от одного ролика к другому через fade белого цвета.
Disclaimer: Мне потребовалось некоторое время, чтобы понять, что делать паузы между роликами на что-либо совершенно неуместно — это отнимает время, рассеивает концентрацию… а уж отсутствие fade-in/out по звуковому каналу это вообще насилие над ушами слушателя.
Окончание ролика
На youtube принято в конце ролика дать зрителю послушать какую-нибудь странную музыку и посмотреть под нее превью своих прошлых выпусков. Ок, сделаем это:
$local_batch = "$converter -i $res_dir/ending.mkv -i $res_dir/OVRL1.mkv -i $res_dir/OVRL2.mkv -loop 1 -i $res_dir/sub.png ";
$local_batch .= "-filter_complex "";
$local_batch .= "[1]scale=iw/$scale_factor:ih/$scale_factor,drawbox=0:0:iw:ih:color=white:t=5 [pip0]; ";
$local_batch .= "[2]scale=iw/$scale_factor:ih/$scale_factor,drawbox=0:0:iw:ih:color=white:t=5 [pip1]; ";
$local_batch .= "[3]scale=iw/$scale_factor:ih/$scale_factor [pip2]; ";
$local_batch .= "[0][pip0] overlay=(main_w-2*overlay_w)/3:main_h/($scale_factor-1)-overlay_h-50:repeatlast=0 [pip_m]; ";
$local_batch .= "[pip_m][pip1] overlay=2*(main_w-2*overlay_w)/3+overlay_w:main_h/($scale_factor-1)-overlay_h-50 [sum]; ";
$local_batch .= "[sum][pip2] overlay=main_w/2-overlay_w/2:2*main_h/3:shortest=1" ";
$local_batch .= "-crf $quality -vb 20M $res_dir/ending.mp4 -y";
system( $local_batch );
Применяя всё тот же --filter_complex и превращение картинки в видео ряд, получаем финишную заставку. Не буду разбирать подробно, механизм работы всё тот же, просто несколько другое использование.
Работа с youtube
Возникает вопрос, что делать с полученным видео? Смотреть самому это, конечно, здорово, но можно и друзьям показать. Решено — запилим канал на ютубе.
Первые мысли были такие: ютуб — это гугл, значит, наверняка, есть библиотека под perl, а документация отменная. Вторые мысли: почему нет библиотеки под perl? Третьи: откуда ошибки в доках? Четвертые: чтоб я еще раз...
:)
В общем пришлось самостоятельно разбираться как работать с ютубом из perl. Граблей я собрал немерянно, так как работать с web из perl'а мне еще не приходилось.
Авторизация на ютубе сделана через oauth2, что на пальцах выглядит так:
- Используя client_id, однократно получаем auth_token. Эта операция обязательно производится с участием человека.
- Используя auth_token, получаем access_token и refresh_token. При этом access — истекает за час, а refresh — постоянный, по нему мы обновляем access.
- Если access_token истек, обновляем его с использованием refresh_token.
Звучит просто, но есть нюансы. Не буду предлагать вам собирать все грабли повторно, просто предложу свой код.
Получаем auth_token
###################################################################
### Одноразовый запрос на получение одобрения от пользователя ###
### Вместе с одобрением получаем auth_token ###
###################################################################
$ua = LWP::UserAgent->new();
open( RESPONSE, ">", $response_file );
$req = POST 'https://accounts.google.com/o/oauth2/auth',
[
scope => "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube",
response_type => "code",
include_granted_scopes => "true",
access_type => "offline",
redirect_uri => "http://localhost/oauth2callback",
client_id => "$client_id"
];
$content = $ua->request($req)->as_string;
print RESPONSE $content;
system("$browser $response_file");
print "Enter auth_token:n";
my $the_code = <STDIN>;
Получаем access и refresh токены
###################################################################
### Одноразовый запрос на получение access и refresh tokens ###
###################################################################
$req = POST 'https://accounts.google.com/o/oauth2/token',
[
code => "$the_code", ### Это и есть auth_token с прошлого шага
client_secret => "$client_secret",
redirect_uri => "http://localhost/oauth2callback",
client_id => "$client_id",
grant_type => "authorization_code",
];
$json = $ua->request( $req )->decoded_content;
$json_text = decode_json( $json );
$refresh_token = $json_text->{'refresh_token'};
$access_token = $json_text->{'access_token'};
print LOG $json;
close RESPONSE;
Обновление доступа
###################################################################
### Многоразовый запрос на получение access token ###
### Получаем access по существующему refresh token ###
###################################################################
$req = POST 'https://accounts.google.com/o/oauth2/token',
[
client_id => "$client_id",
client_secret => "$client_secret",
refresh_token => "$refresh_token",
grant_type => "refresh_token"
];
$content = $ua->request($req)->as_string;
$content =~ m/"access_token"s+:s+"(.*)",.*/;
$access_token = $1;
print "Access token succesfully refreshed: $access_tokenn";
Проверка доступа
###################################################################
### Многоразовый запрос на проверку access token ###
###################################################################
if( $check_access == 1 ){
$req = POST 'https://www.googleapis.com/oauth2/v3/tokeninfo',
[
access_token => "$access_token",
];
$content = $ua->request($req)->decoded_content;
print "$contentn";
}
На этом приключения с ютубом не заканчиваются, так как мы пока только получили авторизацию, а нам надо еще и залить свое видео на канал. И тут появляется очередной нюанс, связанный с тем, что я писал скрипт под windows, а он в известной степени не совместим с linux, в то время как мне нужна была стабильная работа скрипта и там, и там.
Если вы не знали, то сообщаю: нельзя просто так взять и залить видео на ютуб (с).
Сперва нужно сделать запрос, в котором предоставить информацию о предстоящей загрузке, и только потом по известному линку можно будет заливать.
Получение загрузочного линка
$file_size = -s $file;
$headers = HTTP::Headers->new(
'Content-Type' => 'application/json; charset=utf-8',
'Authorization' => "Bearer $access_token",
'x-upload-content-type' => 'video/mp4',
'X-Upload-Content-Length' => $file_size
);
$r = HTTP::Request->new( 'POST', $url, $headers );
$r->content( $message );
$response = $ua->request( $r );
$upload_url = $response->header("Location");
В качестве сообщения мы отправляем корректно сформированный JSON. Тут важно обратить внимание на то, что в документации гугла бинарные опции JSON в некоторых примерах указываются как True/False, но внутренний парсер гугла воспринимает, та-дам!, бинарные опции как true/false. Одна большая буква из копипастного примера может стоить вам приличного количества нервов, ведь возвращаемая ошибка: Parser error.
Загрузка видео
$file_content = read_file( $file, binmode => ':raw', scalar_ref => 1 );
$headers = HTTP::Headers->new(
'Content_Length' => "$file_size",
'Content-Type' => 'video/mp4',
'Authorization' => "Bearer $access_token"
);
$r = HTTP::Request->new('PUT', $upload_url, $headers, $file_content);
$response = $ua->request( $r );
$json = $response->decoded_content;
$json_text = decode_json( $json );
$resp_code = $response->status_line;
$video_id = $json_text->{'id'};
Здесь важна самая первая строчка. Конечно, сослаться на файл можно многими разными способами, но только так perl не влезает в файл и не пытается его открыть, одновременно модифицируя его. По сути, мы делаем ссылку на файл и указываем, как с ней работать: бинарно.
Загрузка превью
$url = "https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=$video_id";
$headers = HTTP::Headers->new(
'Content_Length' => $thumbnail_size,
'Content-Type' => 'image/jpeg',
'Authorization' => "Bearer $access_token",
);
$r = HTTP::Request->new('POST', $url, $headers, $thumbnail_content);
$response = $ua->request($r);
$upload_url = $response->header("Location");
$resp_code = $response->status_line;
print LOG $response->decoded_content;
print "Thumbnail upload init status: $resp_coden";
Заключение
На данный момент я использую клиент-серверный подход для создания роликов. Скрипт, отвечающий за базу данных, крутится на VPS'ке от digitalocean, доступ к которой мне предоставил друг. Кодирование видео — весьма ресурсозатратная штука, поэтому эта задача оставлена на мой домашний ПК. Также из дома я могу по желанию проверить видео, которые пойдут в выпуск, поменять их количество, добавить зацикливание и так далее.
Смотреть контент с других развлекательных каналов я перестал, так как, очевидно, ручная работа других ютуберов значительно отстает в скорости от моего скрипта. А смотреть на древние баяны и рекламу у меня теперь причин нет.
От автора
-
Не бойтесь писать на perl'е — это просто.
-
Когда я только начал писать скрипт, было много головной боли, связанной с тем, что я привык к понятию переменной типа "регистр", и не сразу сообразил, что в perl'e надо использовать ссылки.
-
— Почему ты не пошел на "К", ведь ты классно программируешь?
— Я не пошел на "К", именно потому, что люблю программировать.Разговор двух студентов с кафедры №27(микроэлектроника) МИФИ, К — факультет кибернетики.
Автор: Lerk