切り抜き動画作成の勧めとYoutubeのコメントを可視化する話
この記事はCCS †裏† Advent Calendar 2021 15日目の記事です。
昨日はマナ板さんのメッシャーズの紹介記事となっております。
目次
はじめに
どうも老害㌠いかろちゃんです。
今年はVTuber関連の記事が多くありますね。 それだけみんなVTuberを見ているということなんでしょう。
皆さんに推しのVTuberはいますか?そして応援しているでしょうか? 応援の方法としてはわかりやすい配信を見る、コメントをする、スーパーチャットをするようなことからTwitterでリプをつける、いいねをする、RTをする、ファンアートを描くなどなどたくさんの方法があります(ちなみにこれはわたしが勝手に言ってることではなく、わたしの最推しでありVTuberである因幡はねるちゃんが言っていることです)。 そしてそのひとつに切り抜き動画制作があります。
切り抜き動画とはライブ配信の面白かった箇所などを切り抜いて、編集を加えた動画のことです。 切り抜き動画作成は推しのよいところをダイレクトに人に伝えることができます。 言葉で良さを伝えるより、いいから見て!とできるのでより説得力が増します。 ヲタクは好きなものを人に伝えるの大好きですよね。切り抜き動画を作りたくなってきませんか? そして切り抜き動画は一般に元配信と比べ大幅に短くなるためライトな層にとっても見やすく好都合です。 したがって、切り抜き動画は推しを人々に広めたいヲタク、いきなり生配信をリアルタイムで見るのは重いライトな層両方にとってメリットがある*1のです。
さてTwitterに切り抜き動画を投稿する際に問題となるのが時間制限です。 部分的な切り抜きなら時間内に収めるのはさほど大変ではありません。 しかし、1つの配信をまとめたような動画の場合は時間に収めるという作業が大変となってきます。 そこで客観的な指標に頼りたくなってきます。 そのような指標として考えられるのが時間あたりのコメント数(以下CPT;comment per time)でしょう。 CPTが高いところはコメントが多く、配信も盛り上がっていたと考えられます。 そこでCPTを可視化しまたその時のコメントを表示できるようなツールがあれば切り抜き動画制作において便利である*2と考えられます。
この記事では時間あたりのコメント数を可視化するツールを作ります。 そしてケーススタディとして長時間配信から本ツールを利用して見どころを抽出し実際に切り抜き動画を作成します。 切り抜き動画制作のすすめの記事でもある*3ので切り抜き動画を実際にどう作っているかについても記載します。 プログラミングの部分、切り抜き動画制作の部分どちらかのみに興味がある人はそこだけ読んでも通じるように書いているつもりです*4。
可視化ツールの方針
処理としては大きくYoutubeからコメントを取得する、それを可視化するの2ステップがあります。
Youtubeからコメントを取得する部分については今回は切り抜き動画を作成することが目的ですので、動画のダウンロードと同時に行います。
これはYoutubeからの動画のダウンロードに利用しているyt-dlpというツールが標準機能で備えています。
ということで手間も増えるわけでもないのに実装する必要はありませんのでyt-dlpの機能に頼りましょう。
yt-dlpの--write-subs
オプションでコメントを改行区切りにしたJSONを取得することが可能です。
コメント部分の一例としては下記です。
様々なデータが含まれていますが重要な部分はtext
とvideoOffsetTimeMsec
です。
前者はコメントの内容、後者は開始時点からの経過時間(m sec)です。
{ "replayChatItemAction": { "actions": [ { "addChatItemAction": { "item": { "liveChatTextMessageRenderer": { "message": { "runs": [ { "text": "草" } ] }, "authorName": { "simpleText": "いかろちゃん" }, "authorPhoto": { "thumbnails": [ { "url": "https://yt4.ggpht.com/ytc/AKedOLSiuIye7HiIJiRQjNa68IAJ-ejTewkXBEf2CBwung=s32-c-k-c0x00ffffff-no-rj", "width": 32, "height": 32 }, { "url": "https://yt4.ggpht.com/ytc/AKedOLSiuIye7HiIJiRQjNa68IAJ-ejTewkXBEf2CBwung=s64-c-k-c0x00ffffff-no-rj", "width": 64, "height": 64 } ] }, "contextMenuEndpoint": { "commandMetadata": { "webCommandMetadata": { "ignoreNavigation": true } }, "liveChatItemContextMenuEndpoint": { "params": "Q2tjS1JRb2FRMDVNUkMxeU1rWm9VRkZEUm1Fd1ZISlJXV1JDWlUxSVNtY1NKME5MWldFM2RWOHRaMTlSUTBaa1QxWjNaMjlrYkZJd1NGOUJNVFl6TmpJeE1qSXhNalEyTUJvcEtpY0tHRlZETUU5M1l6TTJWVGxzVDNscE9VZDRPVWxqTFRSeFp4SUxabE0zTlMxWWQybFFabGtnQVNnQk1ob0tHRlZEZW01c2VFRklaMDFGWmtaMExYcDNZbkZJZUVOQ2R3JTNEJTNE" } }, "id": "CkUKGkNOTEQtcjJGaFBRQ0ZhMFRyUVlkQmVNSEpnEidDS2VhN3VfLWdfUUNGZE9Wd2dvZGxSMEhfQTE2MzYyMTIyMTI0NjA%3D", "timestampUsec": "1636212213195265", "authorBadges": [ { "liveChatAuthorBadgeRenderer": { "customThumbnail": { "thumbnails": [ { "url": "https://yt3.ggpht.com/c2UzPBdLkckxF_PjgLfPlsyNWeozx1pTMI6LdH5xL4JO3XozEAyBCEiJZESdeSBdVmzQd1AsEqw=s16-c-k" }, { "url": "https://yt3.ggpht.com/c2UzPBdLkckxF_PjgLfPlsyNWeozx1pTMI6LdH5xL4JO3XozEAyBCEiJZESdeSBdVmzQd1AsEqw=s32-c-k" } ] }, "tooltip": "Member (1 year)", "accessibility": { "accessibilityData": { "label": "Member (1 year)" } } } } ], "authorExternalChannelId": "UCznlxAHgMEfFt-zwbqHxCBw", "contextMenuAccessibility": { "accessibilityData": { "label": "Comment actions" } }, "timestampText": { "simpleText": "1:21:37" } } }, "clientId": "CKea7u_-g_QCFdOVwgodlR0H_A1636212212460" } } ], "videoOffsetTimeMsec": "4897127" } }
あとはこのファイルを解析して図にするだけですね!簡単!
スクリプトをかいてCLIでポチポチするのも趣があってよい*5のですが、今回はブラウザで操作できれば便利だなと思いJavaScriptにコンパイルできる言語(AltJS)を採用することにしました。 具体的な言語としてはElmを採用しました。 ElmはWebアプリケーションを書くことに特化したシンプルで堅牢、そしてなにより書いててストレスがなく楽しい非常に実用的な言語です。 Elmというと関数型言語ということで取り上げられることが多いですが、 実用のために関数型言語のアプローチを採用しているだけで実用性を犠牲にせず極力シンプルな言語となるように設計されています。 ちなみに関数型言語については下記の国大くんの記事でおもしろく解説されています。
具体的な実装内容にしては1. JSONを読み込む、2. グラフ化する、3. 選択した時間のコメント一覧を表示する機能を実装します。 JSONを読み込む、コメントを表示するのはElmの標準ライブラリですればよいでしょう。 グラフ化についてはelm-visualizationというライブラリがあります。
このライブラリはJavaScriptのD3.jsに触発されて開発されたそうです。 一通りのグラフ化に必要な機能、および便利な統計関連の機能があります。 SVGとしてグラフを吐き出してくれます。 なによりサンプルが豊富なのが嬉しい。
ということでなんとなく方針が決まったところで実装していきましょう。
実装
まずはModel考えていきます。 グラフ化する情報、実際の情報は色々ありますが結局のところそれらは生のJSONから導出できます。 導出できるものをもっていてもそれらに矛盾が生じる可能性があるのでModelには生のJSONを保持するrawJsonを持っておきましょう。 また、どの時間のコメントを表示するかを選択するためにその時間の情報が必要ですね。 ということでselectedTimeも持っておきます。
type alias Model = { rawJson : Maybe String , selectedTime : Int }
つぎにMsgとupdateです。 状態遷移としてはJSON選択画面、JSON読込中、JSON読み込み後、選択している時間変更がありそうなので それをそのままMsgとします。 updateもそのまま書きましょう。ちなみにJSONの読み込みにはfileというライブラリを利用しています。
type Msg = JsonRequested | JsonSelected File | JsonLoaded String | ChangeSelectTime String update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of JsonRequested -> ( model , Select.file [ "text/json" ] JsonSelected ) JsonSelected file -> ( model , Task.perform JsonLoaded (File.toString file) ) JsonLoaded content -> ( { model | rawJson = Just content } , Cmd.none ) ChangeSelectTime time -> ( { model | selectedTime = String.toInt time |> Maybe.withDefault 0 }, Cmd.none )
さて生JSONから得られる情報はいつどんなコメントがあったかですので、これからCPTを導出したいです。 CPTを導出するcount関数*6を実装しましょう。 方針としてはエラトステネスの篩みたいな発想でしていきます。 リストからある時間のコメントをすべて抽出して数を計算する、抽出されなかったものは他の時間のコメントである、 という当たり前のことをそのまま実装すればよいです。
count : List a -> List ( a, Int ) count targetList = count_impl [] targetList count_impl : List ( a, Int ) -> List a -> List ( a, Int ) count_impl progressList target = case target of [] -> progressList res :: _ -> count_impl (( res, List.length (List.filter (\n -> n == res) target) ) :: progressList) (List.filter (\n -> n /= res) target)
つぎにJSONのエラーにあたったらそれを取り除くremoveErrorを実装しましょう。 というのも最初の2つは動画のメタデータがはいっているJSON,以降も何かしらのエラーに遭遇するかもしれませんがそれらは集計には不要な情報だからです。
removeError : List (Result err a) -> List a removeError data = List.foldl (\x result -> case x of Err _ -> result Ok val -> val :: result ) [] data
あとはviewの部分ですね。 これはelm-visualizationのサンプルを参考に実装します。 内容についても公式ドキュメント以上の解説は特にありませんので詳しくは書きません。 ということで最終的には下記のようになりました。
module Main exposing (..) import Axis import Browser import Color import File exposing (File) import File.Select as Select import Html exposing (Attribute, Html, button, div, option, p, select, table, td, text, th, tr) import Html.Attributes exposing (style, value) import Html.Events exposing (on, onClick) import Json.Decode exposing (Decoder, at, decodeString, field, index, int, string) import Maybe import Path exposing (Path) import Scale exposing (ContinuousScale) import Shape import Statistics import Task import TypedSvg exposing (defs, g, linearGradient, stop, svg, text_) import TypedSvg.Attributes exposing (class, dy, fill, fontFamily, id, offset, stopColor, stroke, textAnchor, transform, viewBox, x1, x2, y1, y2) import TypedSvg.Attributes.InPx exposing (fontSize, height, strokeWidth, width, x, y) import TypedSvg.Core exposing (Svg, text) import TypedSvg.Types exposing (AnchorAlignment(..), Paint(..), Transform(..), em, percent) -- constants-- w : Float w = 900 h : Float h = 450 arrow : String arrow = "M2.73484 7.26517C2.88128 7.41161 3.11872 7.41161 3.26517 7.26517L5.65165 4.87868C5.7981 4.73223 5.7981 4.4948 5.65165 4.34835C5.5052 4.2019 5.26777 4.2019 5.12132 4.34835L3 6.46967L0.87868 4.34835C0.732233 4.2019 0.494796 4.2019 0.34835 4.34835C0.201903 4.4948 0.201903 4.73223 0.34835 4.87868L2.73484 7.26517ZM2.625 1.639 padding : Float padding = 30 -- type -- type alias Comment = { time : Int , text : String } type alias Comments = List Comment type alias Point = ( Float, Float ) type alias TimeSeries = List Point xScale : TimeSeries -> ContinuousScale Float xScale timeSeries = Scale.linear ( 0, w - 2 * padding ) ( 0 , List.foldl (\( x, y ) z -> if z < x then x else z ) 0 timeSeries ) yScale : TimeSeries -> ContinuousScale Float yScale timeSeries = Scale.linear ( h - 2 * padding, 0 ) ( 0 , List.foldl (\( x, y ) z -> if z < y then y else z ) 0 timeSeries + 50 ) xAxis : TimeSeries -> Svg msg xAxis timeSeries = Axis.bottom [ Axis.tickCount (List.length timeSeries // 100) ] (xScale timeSeries) yAxis : TimeSeries -> Svg msg yAxis timeSeries = Axis.left [ Axis.tickCount 5 ] (yScale timeSeries) transformToLineData : TimeSeries -> Point -> Maybe Point transformToLineData timeSeries ( x, y ) = Just ( Scale.convert (xScale timeSeries) x, Scale.convert (yScale timeSeries) y ) tranfromToAreaData : List ( Float, Float ) -> ( Float, Float ) -> Maybe ( ( Float, Float ), ( Float, Float ) ) tranfromToAreaData timeSeries ( x, y ) = Just ( ( Scale.convert (xScale timeSeries) x, Tuple.first (Scale.rangeExtent (yScale timeSeries)) ) , ( Scale.convert (xScale timeSeries) x, Scale.convert (yScale timeSeries) y ) ) line : TimeSeries -> Path line timeSeries = List.map (transformToLineData timeSeries) timeSeries |> Shape.line Shape.monotoneInXCurve area : TimeSeries -> Path area timeSeries = List.map (tranfromToAreaData timeSeries) timeSeries |> Shape.area Shape.monotoneInXCurve -- MAIN main : Program () Model Msg main = Browser.element { init = init , view = view , update = update , subscriptions = subscriptions } -- MODEL type alias Model = { rawJson : Maybe String , selectedTime : Int } init : () -> ( Model, Cmd Msg ) init _ = ( Model Nothing 0, Cmd.none ) -- UPDATE type Msg = JsonRequested | JsonSelected File | JsonLoaded String | ChangeSelectTime String update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of JsonRequested -> ( model , Select.file [ "text/json" ] JsonSelected ) JsonSelected file -> ( model , Task.perform JsonLoaded (File.toString file) ) JsonLoaded content -> ( { model | rawJson = Just content } , Cmd.none ) ChangeSelectTime time -> ( { model | selectedTime = String.toInt time |> Maybe.withDefault 0 }, Cmd.none ) -- VIEW count : List a -> List ( a, Int ) count targetList = count_impl [] targetList count_impl : List ( a, Int ) -> List a -> List ( a, Int ) count_impl progressList target = case target of [] -> progressList res :: _ -> count_impl (( res, List.length (List.filter (\n -> n == res) target) ) :: progressList) (List.filter (\n -> n /= res) target) videoOffsetTimeDecoder : Decoder Int videoOffsetTimeDecoder = at [ "replayChatItemAction", "videoOffsetTimeMsec" ] string |> Json.Decode.map (\s -> String.toInt s |> Maybe.withDefault 0) commentDecoder : Decoder String commentDecoder = at [ "replayChatItemAction", "actions" ] <| index 0 <| at [ "addChatItemAction", "item", "liveChatTextMessageRenderer", "message", "runs" ] <| index 0 <| field "text" string removeError : List (Result err a) -> List a removeError data = List.foldl (\x result -> case x of Err _ -> result Ok val -> val :: result ) [] data view : Model -> Html Msg view model = case model.rawJson of Nothing -> button [ onClick JsonRequested ] [ Html.text "Load JSON" ] Just content -> let resolution = 60000 comments = String.split "\n" content |> List.map (decodeString <| Json.Decode.map2 Comment videoOffsetTimeDecoder commentDecoder) |> removeError |> List.map (\comment -> { comment | time = comment.time // resolution }) |> List.filter (\comment -> comment.time == model.selectedTime) |> Debug.log "com:" compileList = String.split "\n" content |> List.map (decodeString videoOffsetTimeDecoder) |> removeError |> List.map (\x -> x // resolution) |> count |> List.map (\( x, y ) -> ( toFloat x, toFloat y )) |> Debug.log "res:" in div [] [ view_graph compileList, viewSelectTime compileList, viewComment comments ] viewComment : Comments -> Html Msg viewComment comments = table [] <| tr [] [ td [] [ Html.text "comment" ] ] :: List.map (\comment -> tr [] [ td [] [ Html.text comment.text ] ]) comments onChange : (String -> msg) -> Attribute msg onChange handler = on "change" (Json.Decode.map handler Html.Events.targetValue) viewSelectTime : TimeSeries -> Html Msg viewSelectTime timeSeries = select [ onChange ChangeSelectTime ] <| List.map (\( time, _ ) -> option [ value <| String.fromFloat time ] [ Html.text <| String.fromFloat time ]) timeSeries peaksView : TimeSeries -> List (Svg msg) peaksView data = data |> Statistics.peaks Tuple.second { lookaround = 50, sensitivity = 1, coallesce = 25 } |> Debug.log "peeks:" |> List.map (\( x, y ) -> let xpos = Scale.convert (xScale data) x anchor = if xpos - padding < 50 then AnchorStart else if xpos + padding > w - 50 then AnchorEnd else AnchorMiddle in g [ transform [ Translate (xpos + padding) (Scale.convert (yScale data) y - 12) ] ] [ TypedSvg.path [ TypedSvg.Attributes.d arrow, fill (Paint Color.red) ] [] , TypedSvg.text_ [ fontSize 11, fontFamily [ "sans-serif" ], textAnchor anchor, TypedSvg.Attributes.InPx.y -5 ] [ TypedSvg.Core.text <| String.fromFloat x ] ] ) view_graph : TimeSeries -> Html msg view_graph timeSeries = div [] [ svg [ viewBox 0 0 w h ] [ g [ transform [ Translate (padding - 1) (h - padding) ] ] [ xAxis timeSeries ] , g [ transform [ Translate (padding - 1) padding ] ] [ yAxis timeSeries ] , g [ transform [ Translate padding padding ], class [ "series" ] ] [ Path.element (area timeSeries) [ strokeWidth 3, fill <| Paint <| Color.rgba 1 0 0 0.54 ] , Path.element (line timeSeries) [ stroke <| Paint <| Color.rgb 1 0 0, strokeWidth 1, fill PaintNone ] ] , g [] <| peaksView timeSeries ]
ケーススタディ
ケーススタディとして最適なのは長時間配信で実際に盛り上がっているところを抽出できるかでしょう。 今回はわたしの推しの一人であり、長時間配信に定評のある?白宮みみちゃんの配信から切り抜き動画を作ってみましょう。 今回対象とするのは下記のGetting Over It with Bennett Foddy、通称壺おじの耐久配信です。 10時間もあれば文句ないですね。
まずは対象の動画とコメントをダウンロードします。 オプションはmp4形式で最高画質となるようにダウンロードし、コメント等の他の情報もあわせてダウンロードするようにしています。
yt-dlp --write-subs -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" --merge-output-format mp4 https://www.youtube.com/watch?v=1eepJJ2IdEI
これでダウンロードされたjsonがコメントファイル、mp4が動画本体となります。 ここで今回作ったツールを利用します。ダウンロードしたJSONを読み込んでみましょう。
いくつかのピークが見られますが、今回は250分のところのピークのコメントを見てみます。
「なんこれなん」がキーワードとしてありそうです。 ということで250分の1分前あたり*7から動画を見てみます。 どうやら落下してしまい唐突に自作の歌を歌い始めたようですね...かわいい... 切り抜くのは歌だけでもいいんですが、その前の声にならないうめき?やおそらくふとももペチペチしているところもかわいい... そしてこの落下のみみ虐とその後の空気感いいですね... なによりアホっぽい言動しておきながら、実は前日からの2日連続耐久配信でめげずに続けてるそのガッツがとても好き... ハッ...ヲタクが出てしまいました。内容に戻りましょう。 そこで今回はちょっと長めになってしまいます*8が落下してから歌い終わるまでを切り抜くことにしました。
切り抜く範囲が決まったところで動画編集ソフトに取り込みます。 わたしはPremiere Proを使っていますが何でもいいと思います。 あとは切り抜いて字幕つけて、必要に応じてエフェクトつけて完成ですね。 ちなみに編集途中は下記みたいな感じです。
あとは書き出してTwitterに投稿するのみ。 この時TwitterのMediaStudioの機能を使うと便利です。 サムネイルを変えたり、動画をクリックすると元配信を開けるようにしたり(Call to action)予約投稿ができたりします。 とくにファンとして切り抜き動画を作る際は推しのチャンネル登録をしてもらって見てもらうというのが最重要になりますので、 Call to actionは確実に貼っています。 また同じファンの人(あるいは推し本人)が見つけて拡散してくれることもあるので、切り抜き動画タグがある場合は忘れずにつけましょう*9。気になった人が別の切り抜き動画を探す助けにもなります。
ということで投稿しました。
【切り抜き】落ちて奇声を上げたあと謎の歌を歌い始める白宮みみ #みみの黙示録 #白宮みみ 落ちて脳死になる感じかわいい。
— いかろちゃん™💛 (@ikaro2718) 2021年12月15日
元配信の該当箇所へはこの動画クリックで飛べます!
↓白宮みみTwitter↓https://t.co/JAXHfsfJ0a pic.twitter.com/N2Of6VIche
ちなみに切り抜き動画作成に関する情報は下記にまとめています。 わたしはMacを利用していますが、Windowsの情報も載っていますもし気になった人がいたらどうぞ。
評価
切り抜き動画作成にかけた時間はダウンロード時間を除くと約40分です。 そして当然ながらほぼ動画編集の時間です。
一応ツイートのインプレッション関連情報やアナリティクスを見てみましょう(投稿後約8時間)。 少なくとも0いいねRTではないこと、再生時間が一人あたり約0.5分であることからそれなりに見てもらえたんじゃないでしょうか?...
追記:その後、15RT,30いいね、リンクのクリックが10超えました。 まずまずの成功ではないでしょうか。
考察・まとめ
今回は時間あたりのコメント数が見どころの客観的指標になるのではという仮説の元それを可視化するツールを作成しました。 今回はツールの作成にElmを利用しましたが、このようなものが作れる程度にはライブラリが豊富であり、 また普段フロントエンドを全く触らない筆者であっても扱える簡単さを備えていることがわかりました。
そしてツールを利用することによりたしかに面白いところの抽出に成功しました。 そのおかげで作業時間の殆どを動画編集に当てることができました。 ただまだ色々粗があるので改善して一般公開できるようにしたいですね...
ただツールによる抽出には功罪があると思っていて、 おそらく万人に刺さるものは抽出できますが少ない人に深く刺さるというのはやはり自分の目で見てよいと思ったところを切り抜くことだと思います。 こちらについては評価できていませんが、おそらくTwitterに投稿する場合は似たような価値観の人がFF関係にあると思いますので、 感性が近い人が多く自分の感性で切り抜きを作るのがいいんじゃないかと思います。
ということでどうしょうもないときはほどほどにツールの力を使いつつ、推しへの愛を持って切り抜きを作っていきましょう。
ちなみにわたしの作った切り抜きは下記のモーメントにまとまっています。 ぜひわたしの推したちをよろしくお願い致します!!!
https://twitter.com/i/moment_maker/preview/1442928180029898753 https://twitter.com/i/moment_maker/preview/1373265486046097408
次のキキくんの記事です。好きなことが並んでる記事はいいですね。
*1:とはいえ人によって切り抜き動画に対する考え方が違ったり、切り抜き動画作成を許可しているものの作っていい切り抜きに制限がある場合がありますので各個人、各企業の切り抜き動画についての規約はしっかり読みましょう。
*2:といっても最終的には推しに対する愛がいちばん大事な要素だと思います。少なくともわたしはお金上げるからひろゆきの切り抜き動画を作ってと言われても作ろうとは思いません。
*3:本当はこちらがメインです。が、例年プログラミングに多少とも関わりある記事を書いているので今年も書きたい。ということで主題が2つある記事になってしまいました。
*4:この文章を書いた時点では少なくともそう思っていた
*5:ちなみにいままでは古き良きシェルでワンライナーでガリガリやってました
*7:コメントを打つ速度、反映速度から実際の反応がよかったシーンとコメント投稿までにはラグがあります
*8:Twitterだとだいたい10秒毎に動画を最後まで視聴してくれる人の割合が半減していくのでできれば短いほうがいい
*9:とはいっても最近は動画とタグの混在はシャドウバン率があがっている気がする。難しい問題