歌詞動画を作った。
最初の試作は、つまらなかった。言葉は整っている。でも、自分の声に聞こえなかった。
作り直したのに、元動画のままに見えた。だから参考MP4の画は使わず、OCRで時刻だけ拾った。GIF3種と線画6種をJSONで切り替え、Pythonで描いて、ffmpegでMP4にした。
音はAbstract boom bap。画面は黒いカードと白線画。文字は下の黒帯にタイプする。約172秒の縦長PVになった。
なぜ作り直したのか
作り直したのに、元動画のままに見えた。字幕も演出ラベルも残っていた。それが嫌だった。
使わないものを先に決めた。
- 参考動画の画素、字幕、クレジットは使わない
- 参考動画は時刻だけ見る
- 歌詞と画の対応はJSONに置く
- GIFの古い文字帯は黒で隠す
- 歌詞は自分のタイムラインから打つ
- 元のPV v2は上書きしない
作りたかった映像
音のメモは、Abstract sample collage hip hop。ほこりっぽいドラム、壊れかけたサンプル、ゆるいMPCの揺れ。ループは戻るけど、少しだけ形を変える。
画面はこうした。
- 背景は黒
- 画は単純な白線
- 動きは大きくしない
- 文字は下に置く
- 入力中のカーソルを残す
- 演出ラベルは歌詞として出さない
- 文字と線画は同じ時刻に切り替える
使った素材
材料はこれ。
既存のGIFはCRT、ノート、カセットの3種類だった。
追加した線画はこの6種類。
- CRTモニター
- 紙コップに入った小さな計画
- 壁の向こうのサックス
- 汚れのついたノート
- 少し開いたドア
- テープがほどけたカセット
参考MP4は画を抜くためには使っていない。10fpsだったので、0.5秒ごとにOCRして字幕の切り替わりだけ見た。
GIFには試作時の文字が残っていた。二重になるので下を黒で隠した。歌詞はJSONから打ち直した。
システムの流れ
システムは単純。
MP3と歌詞タイムラインJSONをレンダラーに渡す。画はGIF3種と線画PNG6種。レンダラーがその時刻の歌詞と画を探して、毎フレーム描く。最後にffmpegで音源を合わせてMP4にする。
- 参考MP4:字幕の時刻を見る
- タイムラインJSON:歌詞、時刻、モチーフ、画像ID
- GIF:フレーム時間を維持してループ
- 線画PNG:マスク、拡大、Yoyo移動
- Python / Pillow:毎フレームの画面生成
- ffmpeg:映像と音声の合成
歌詞はコードに書かなかった。時刻を直すときはJSONだけ触って、同じレンダラーを回す。
技術仕様
- 出力:171.733秒
- 画面:720×1280
- フレームレート:15fps
- フレーム数:2576
- 映像:H.264 / yuv420p
- 音声:AAC 192kbps
- 文字:Arial Bold 50px
- 文字帯:画面の70%〜88%
- 歌詞表示:Typewriter
- 歌詞イベント:55件
- 角括弧ラベル:0件
線画は、最初の0.68秒で左から右へ現れるマスクを使う。画像は最大6%までゆっくり拡大し、8px程度のYoyo移動を加える。
動かしすぎない。カメラワークを大きくすると、音の細かい揺れより映像の演出が前に出るからだ。
歌詞をJSONにした理由
歌詞と画を別々に直したかったので、イベントに次の情報を置いた。
- section:Intro、Verse、Hookなどの区分
- start:表示開始時刻
- end:表示終了時刻
- text:表示する歌詞
- confidence:参考動画から読んだ時刻の確からしさ
- motifId:同じモチーフをまとめるためのID
- visualId:開始時に表示するGIFまたは線画のID
たとえば、サックスの線画を出す歌詞はこうなる。
{
"section": "Verse 2",
"start": 89.0,
"end": 91.0,
"text": "Sax in the wall like a voice next door",
"confidence": 0.91,
"motifId": "verse-two-sax",
"visualId": "line-art-03-sax-wall"
}
角括弧の演出ラベルはデータに入れていない。歌詞だけを描画へ渡すので、字幕には出ない。
時刻を調整した
最初は、歌詞をセクションごとに並べただけの近似タイムラインだった。
20秒テストで、GIFを背景にして、古い文字帯を黒で隠し、自分の歌詞をタイプした。
その後、参考MP4のOCR結果を見直した。
- 参考MP4は10fps
- OCRは0.5秒ごと
- Verse 1は約0.5秒ずれていた
- Verse 2とBridgeは、近似タイムラインより約3〜4秒早かった
ここを直して、タイムラインを"ocr-calibrated-pass-2"として保存した。歌詞の開始時刻とvisualIdは同じにした。
OCRが示すのは字幕が見えた時刻だ。実際の発声とは少し違う。ここはまだ耳で合わせるところが残っている。
レンダリング処理
レンダラーは毎フレームこう動く。
- 音源から全体の長さを取る
- 15fps分のフレームを作る
- その時刻の歌詞イベントを探す
- その時刻の画イベントを探す
- GIFなら該当時刻のフレームを読む
- GIFの旧文字帯を黒で隠す
- PNGならマスク、拡大、Yoyo移動を計算する
- 音量とビートに合わせて、ごく弱く暗転する
- 下の黒帯へTypewriter文字を描く
- raw RGBフレームをffmpegへ渡す
- 元音源をAACとして合成する
Typewriterはイベント開始から0.52秒で全文を出す。表示途中だけカーソルを出す。
検証したこと
できたあと、壊れていないか確認した。
- Pythonの構文チェック
- ffprobeで尺、サイズ、fps、フレーム数を確認
- 音声ストリームを確認
- ffmpegで全編デコード
- JSONの開始と終了の順序を確認
- イベント間の重複を確認
- 角括弧ラベルの件数を確認
- 代表時刻の静止画を確認
- MP4とJSONのSHA-256を保存
結果はこうだった。
- MP4は171.733秒
- 720×1280、15fps、2576フレーム
- H.264とAACを含む
- 全編デコード成功
- 歌詞イベント55件
- 角括弧ラベル0件
手元で再生成する
作品フォルダで次を実行する。
python3 -m py_compile render_full_gif_pv.py
python3 render_full_gif_pv.py
ffprobe -v error -show_entries format=duration:stream=index,codec_name,codec_type,width,height,r_frame_rate,nb_frames -of default=noprint_wrappers=1 run-it-again-pv-full-gif-lyrics.mp4
ffmpeg -v error -i run-it-again-pv-full-gif-lyrics.mp4 -f null -
現在のスクリプトは、ぼくのMacにある音源の場所をrender_pv.pyから読んでいる。別の環境では音源パスを変える。
記事用EYも作った
EYも作った。PV本編の黒い線画をそのままアイキャッチにはせず、明るい16:9にした。
CRT、カセット、ノート、タイムラインを一枚にまとめた。タイトルは"Run it again."、補助コピーは「歌詞動画を、作り直す。」。
まとめ
171.733秒のMP4ができた。歌詞イベントは55件。角括弧ラベルは0件。構文、映像情報、全編デコード、JSONの順序と重複も確認した。
記事と動画は公開済み。まだ発声との最終同期と、Bridgeの切り替え判断が残っている。
元PV v2、元GIF、音源は上書きしていない。












