attrip. Search
音楽

LATEST BEAT

AIっぽい歌詞動画を、データから作り直す。Run it again. 制作記録と技術仕様

2026.08.04 · 8 MIN READ · 理解の記録

RECORD / 2026

歌詞動画を作った。

最初の試作は、つまらなかった。言葉は整っている。でも、自分の声に聞こえなかった。

作り直したのに、元動画のままに見えた。だから参考MP4の画は使わず、OCRで時刻だけ拾った。GIF3種と線画6種をJSONで切り替え、Pythonで描いて、ffmpegでMP4にした。

音はAbstract boom bap。画面は黒いカードと白線画。文字は下の黒帯にタイプする。約172秒の縦長PVになった。

Run it again. 全尺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、音源は上書きしていない。

コメントを残す