"Реклама — двигатель прогресса" — эта легкая фраза, сказанная невзначай моей сестрой, описывает практически весь путь разработки простенького скрипта, который со временем вырос в небольшое клиент-серверное приложение. Итак, в данной статье я расскажу про: авторизацию на 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 не умеет отрезать от видео один пиксель, поэтому где-то вам придется привести размер видео к четному. Где вы это будете делать — на свое усмотрение.


Тёмная магия


Даже не столько магия, сколько способ сложно сделать простой эффект на видео. Требовалось сделать:


  1. Оверлейный полупрозрачный бокс с названием с fade-out в альфа канал. (Иначе говоря, плавно пропадающее вместе с боксом название)
  2. 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, что на пальцах выглядит так:


  1. Используя client_id, однократно получаем auth_token. Эта операция обязательно производится с участием человека.
  2. Используя auth_token, получаем access_token и refresh_token. При этом access — истекает за час, а refresh — постоянный, по нему мы обновляем access.
  3. Если 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_token\n";



Проверка доступа


###################################################################
###  Многоразовый запрос на проверку 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 "$content\n";
}

На этом приключения с ютубом не заканчиваются, так как мы пока только получили авторизацию, а нам надо еще и залить свое видео на канал. И тут появляется очередной нюанс, связанный с тем, что я писал скрипт под 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_code\n";



Заключение


На данный момент я использую клиент-серверный подход для создания роликов. Скрипт, отвечающий за базу данных, крутится на VPS'ке от digitalocean, доступ к которой мне предоставил друг. Кодирование видео — весьма ресурсозатратная штука, поэтому эта задача оставлена на мой домашний ПК. Также из дома я могу по желанию проверить видео, которые пойдут в выпуск, поменять их количество, добавить зацикливание и так далее.


Смотреть контент с других развлекательных каналов я перестал, так как, очевидно, ручная работа других ютуберов значительно отстает в скорости от моего скрипта. А смотреть на древние баяны и рекламу у меня теперь причин нет.




От автора


  • Не бойтесь писать на perl'е — это просто.


  • Когда я только начал писать скрипт, было много головной боли, связанной с тем, что я привык к понятию переменной типа "регистр", и не сразу сообразил, что в perl'e надо использовать ссылки.


  • — Почему ты не пошел на "К", ведь ты классно программируешь?
    — Я не пошел на "К", именно потому, что люблю программировать.


    Разговор двух студентов с кафедры №27(микроэлектроника) МИФИ, К — факультет кибернетики.


Комментарии (22)


  1. yegorf1
    11.04.2016 13:45
    -2

    Здравствуйте! Вы не указали ссылку на оригинальный пост, взятый с xkcd.ru, нарушив тем самым лицензию комикса.


    1. Lerk
      11.04.2016 14:17
      -1

      Добрый день!
      Сразу замечу, что минус не мой ;)

      Когда писал пост, даже не задумался, что кто-то не знает авторства комикса. С вашей подачи провел «разбор полетов». Так вот, докладываю.
      1. На xkcd.ru нет указания лицензирования контента, есть просто ссылка на лицензию.
      2. На xkcd.com автор прямо пишет, что его комиксы свободного использования(и есть прямое указание на тип лицензирования). При этом пишет это в конце страницы, после кучи линков на встраивание комикса. А уже в деталях по ссылке сообщает, что нужно указывать авторство.

      Т.к. я не эксперт по авторскому праву, то не могу точно сказать, нарушаю ли я что-то, не указывая лицензию, под которой _должна была быть_ лицензирована некая картинка :-)


      1. uncleLem
        11.04.2016 15:34

        Окей, ссылка на лицензию — не самый прозрачный намек, признаём и в будущем поправим. Но второй пункт — это-то вообще о чем? На обоих сайтах указана лицензия и дана ссылка на нее для ознакомления. Не нужно быть экспертом в области авторского права, чтобы пройти по ссылке и прочитать пару простых абзацев. Или для не-экспертов нужно сразу в шапке сайта писать большими красными буквами «ПОЖАЛУЙСТА, УКАЗЫВАЙТЕ, ОТКУДА ВЫ ВЗЯЛИ КАРТИНКУ, А ЕЩЕ НЕ ПРОДАВАЙТЕ ЕЕ, СПАСИБО ЗА ВНИМАНИЕ»? Это все-таки ваш промах и нужно добавить ссылку на исходную страницу, откуда взят комикс, именно в этом суть лицензии в той части, где говорится про Attribution.


        1. Lerk
          11.04.2016 15:57

          Если вы автор, то вы большой молодец, но тем не менее, если вы не указали лицензирование, то это ваш косяк, и не надо его перекладывать на меня.

          PS. тем не менее, я добавлю указание на источник :)


          1. uncleLem
            11.04.2016 16:25

            Она указана. И даже дана ссылка на нее. Это, конечно, не большие красные буквы в шапке сайта, но это является указанием на лицензию. И, кстати, размеры этой ссылки даже крупнее кнопок, предлагаемых Creative Commons. Более того, если бы лицензия не была указана, то по умолчанию все субъекты авторского права лицензируются обычным копирайтом, который куда строже большинства лицензий Creative Commons.


            1. Lerk
              11.04.2016 16:35
              -1

              К чему эта полемика?) Хотите услышать моё мнение — пишите в личку. Нет — не засоряйте топик.


              1. uncleLem
                11.04.2016 16:46
                +1

                Мне не важно ваше мнение, мне важно, чтобы в статье была ссылка на источник. По-моему, мы не так много просим.


  1. mr_elzor
    11.04.2016 13:58
    -2

    Писать на перле просто, а вот сопровождать написанное…


    1. Lerk
      11.04.2016 14:23

      Разве это не зависит больше от автора кода? Конечно, на перле можно писать совершенно невнятные, но работающие конструкции, но насколько мне известно в сообществе такой стиль совсем не одобряется.


      1. parserpro
        12.04.2016 12:44

        Плюсую.
        Тонны легаси-говнокода создают впечатление что все на Perl пишут именно так, но оно ошибочно.


  1. Neuronix
    11.04.2016 14:08

    Боги, как же я матерился когда делал реализацию заливки видео на ютуб. Я скажу, что на перле это еще выглядит нормально, по сравнению с лапшой на Java. Брррр…


    1. Lerk
      11.04.2016 14:12

      В конечном виде выглядит вполне пристойно :) Для Java же есть библиотека от гугла, там все совсем грустно?


      1. Neuronix
        11.04.2016 14:16

        Есть. Худший и невнятнейший API, который я видел.


        1. Lerk
          11.04.2016 14:20

          К тому же и неполный. Аннотации через API так и не добавить, хотя тикет висит уже годы.


          1. vikarti
            11.04.2016 18:04

            там не только в Java API проблемы
            например https://code.google.com/p/gdata-issues/issues/detail?id=3863 в API для iOS не исправлен до сих пор. проблема решается однострочным хаком но все же.


        1. kAIST
          11.04.2016 15:35

          Не только у Java ) раз в пол года, пытаюсь сесть и написать небольшой скрипт на питоне, который использует API гугла, в итоге плюю на это дело.


  1. Stroy71
    11.04.2016 14:18

    По поводу заголовка. Реклама это та фигня, с помощью которой можно убедить любого в нужности и полезности абсолютно ненужных и бесполезных вещей.


    1. Lerk
      11.04.2016 14:19

      Вполне вероятно) Но в данном случае она меня сподвигла не тратить бездумно вечера, а написать небольшое приложение, попутно прокачав скиллы в программировании.


  1. netaholic
    11.04.2016 15:20

    Здравствуйте. А можно взглянуть на результат?


  1. Lerk
    11.04.2016 16:03

    Ввиду того, что я почему-то не могу отредактировать пост, добавляю по чересчур настойчивой просьбе uncleLem информацию о том, что, очевидно, КДПВ стянута с xkcd.ru, которые утверждают, что лицензируются по CC BY-NC 2.5, хотя указания об этом на самом сайте нет.