YouTubeで歌の動画を投稿するときに、字幕機能で歌詞を付けることがあると思います。しかし、普通は一行一行、文字は固定でカラオケの形式ではなく、テロップのような形だと思います。これを、カラオケのように滑らかに表示することを目指しました。
本記事では、LRC形式で作成した歌詞を、1文字1文字色を変えながら表示する字幕を作成するツールをPythonで作りました。要はLRC→YTTの変換ツールです。
文字の色を左右で分割することは出来ないので、文字の色をゆっくりと変えることで、カラオケの歌詞に近い状態にしています。
※今後機能を変更したり拡張したりする可能性があります。
どんなことができる?
字幕をオンにして以下の動画をご視聴ください。
※埋め込み画面だと文字が小さいため、新しいタブでの視聴をおすすめします。
YouTubeの字幕の仕様
YouTubeでは様々な形式の字幕を利用できますが、複雑な文字装飾は使えないことが多いです。
拡張的な字幕を用いるには、主に「YTT形式」の字幕が必要となります。詳細については以下に示す記事をご確認ください。
このYTT形式を使って字幕を表現すれば、カラオケのように動的に変化する文字も表現可能になるということです。
ツールの特徴
- 歌っている文字の色が、時間経過に合わせて滑らかに変化します。再生前後の文字の色を指定できます。
- 文字に縁取りをつけることができます。文字色と同様に再生前後で色を指定できます。
- 文字の色変化を、一文字あたり何フレーム使って行うかを指定できます。デフォルトでは15ステップ(1文字の色切り替えに15ステップ使う設定)です。文字の量が多かったり密な場合は字幕ファイルが大きくなる可能性があるため、下げたほうがいいかもしれません。
- 一行だけ表示して、行の表示開始時間と表示後の表示終了時間をカスタムできます。
- スマホなどの環境では文字の色のみが適用され、縁取りは無視されます。
仕様
本ツールは、文字単位で同期したLRCファイルが必要です。RhythmicaLyricsなどのソフトで作成できます。文字コードはUTF-8推奨です。
Pythonで動作するため、Pythonがターミナル上で実行可能な環境が必要です。
コードと実行手順
本プログラムをファイルとして保存したあと、python lrc2ytt.py "song.lrc"などのように実行してください。ファイルは実行時のディレクトリに生成されます。生成されたファイルをYouTube Studioの「字幕」メニューからアップロードして保存すると、動画に字幕がつきます。変更したい場合は一度削除してください。
import sys
import os
import re
from datetime import timedelta
# ==========================================
# ユーザー設定エリア (ここを書き換えてカスタマイズ)
# ==========================================
# --- 1. 色の設定 (HTMLカラーコード) ---
# 文字の中身 (Fill)
FILL_COLOR_FUTURE = "#FFFFFF" # 歌う前 (白)
FILL_COLOR_PAST = "#38B3FF" # 歌った後 (水色)
# 文字の輪郭 (Outline)
OUTLINE_COLOR_FUTURE = "#0015FF" # 歌う前 (青)
OUTLINE_COLOR_PAST = "#FFFFFF" # 歌った後 (白)
# --- 2. フォント装飾の設定 ---
FONT_SIZE = 100 # 文字サイズ (おそらく変更しても意味ない?)
EDGE_TYPE = 3 # 輪郭の種類 (3:袋文字/輪郭のみ, 4:ドロップシャドウ)
# --- 3. グラデーションの設定 ---
ENABLE_GRADIENT = True # True: 滑らかに変化させる, False: パッと切り替える
GRADIENT_STEPS = 15 # 何段階で色を変化させるか (数値が大きいほど滑らかだがデータ量が増える)
# --- 4. タイミングの設定 (ミリ秒単位) ---
PRE_ROLL_MS = 3000 # 【表示開始】歌い出しの何秒前から表示するか
POST_ROLL_MS = 2000 # 【表示終了】歌い終わってから何秒残すか (基本値)
GAP_TO_NEXT_MS = 3000 # 【表示終了】次の行の歌い出しの何秒前には消すか
# ==========================================
# 内部ロジック (ここから下は変更不要)
# ==========================================
def hex_to_rgb(hex_code):
"""16進数カラーコードをRGBタプルに変換"""
hex_code = hex_code.lstrip('#')
return tuple(int(hex_code[i:i+2], 16) for i in (0, 2, 4))
def rgb_to_hex(rgb):
"""RGBタプルを16進数カラーコードに変換"""
return "#{:02X}{:02X}{:02X}".format(*rgb)
def interpolate_color(start_hex, end_hex, ratio):
"""2つの色の間を比率(0.0~1.0)で補間する"""
s_rgb = hex_to_rgb(start_hex)
e_rgb = hex_to_rgb(end_hex)
r = int(s_rgb[0] + (e_rgb[0] - s_rgb[0]) * ratio)
g = int(s_rgb[1] + (e_rgb[1] - s_rgb[1]) * ratio)
b = int(s_rgb[2] + (e_rgb[2] - s_rgb[2]) * ratio)
return rgb_to_hex((r, g, b))
def generate_gradient_lists(steps):
"""設定に基づいてグラデーション用の中間色リスト(中身・輪郭)を生成"""
fill_list = []
outline_list = []
for i in range(steps):
ratio = (i + 1) / steps
# 中身の色を補間
fill = interpolate_color(FILL_COLOR_FUTURE, FILL_COLOR_PAST, ratio)
fill_list.append(fill)
# 輪郭の色を補間
outline = interpolate_color(OUTLINE_COLOR_FUTURE, OUTLINE_COLOR_PAST, ratio)
outline_list.append(outline)
return fill_list, outline_list
# グラデーション色の事前計算
if ENABLE_GRADIENT:
GRADIENT_FILLS, GRADIENT_OUTLINES = generate_gradient_lists(GRADIENT_STEPS)
else:
# グラデーションOFFの場合は最終色のみを使用
GRADIENT_FILLS = [FILL_COLOR_PAST]
GRADIENT_OUTLINES = [OUTLINE_COLOR_PAST]
GRADIENT_STEPS = 1
def parse_lrc_time(lrc_time: str) -> int:
"""LRCのタイムタグ [mm:ss.xx] をミリ秒に変換"""
match = re.search(r"(\d{2}):(\d{2})[:\.](\d{2,3})", lrc_time)
if not match: return 0
minutes, seconds, subseconds = map(int, match.groups())
# 2桁(1/100秒)か3桁(1/1000秒)かで調整
if len(match.group(3)) == 2: milliseconds = subseconds * 10
else: milliseconds = subseconds
return (minutes * 60 * 1000) + (seconds * 1000) + milliseconds
def create_ytt_header() -> str:
"""YTTファイルのヘッダー(スタイル定義)を生成"""
# YouTube Timed Text (SRV3) 形式のヘッダー
# Pen ID 1: 歌唱済み (Past)
# Pen ID 2: 歌唱前 (Future)
# Pen ID 10~: グラデーション用の中間色
header = f"""<?xml version="1.0" encoding="utf-8" ?>
<timedtext format="3">
<head>
<pen id="1" sz="{FONT_SIZE}" et="{EDGE_TYPE}" fc="{FILL_COLOR_PAST}" ec="{OUTLINE_COLOR_PAST}" fo="255" bo="0" />
<pen id="2" sz="{FONT_SIZE}" et="{EDGE_TYPE}" fc="{FILL_COLOR_FUTURE}" ec="{OUTLINE_COLOR_FUTURE}" fo="255" bo="0" />
"""
# グラデーション用のペン定義 (ID 10〜)
for i in range(len(GRADIENT_FILLS)):
pen_id = 10 + i
f_color = GRADIENT_FILLS[i]
o_color = GRADIENT_OUTLINES[i]
header += f' <pen id="{pen_id}" sz="{FONT_SIZE}" et="{EDGE_TYPE}" fc="{f_color}" ec="{o_color}" fo="255" bo="0" />\n'
header += "</head>\n<body>\n"
return header
# --- クラス定義: 1行分の歌詞データ ---
class LrcLine:
def __init__(self, raw_text, segments, line_start_ms):
self.full_text = raw_text # 行全体のテキスト
self.segments = segments # 文字ごとのタイムタグ情報
self.start_ms = line_start_ms # 歌い出し時間
self.singing_end_ms = segments[-1]['start_ms'] # 歌い終わり時間
# 画面への表示期間(後の工程でスケジュール計算して決定)
self.display_start = 0
self.display_end = 0
def is_visible(self, t):
"""指定時刻 t にこの行が表示されているか"""
return self.display_start <= t < self.display_end
def render_at(self, t):
"""指定時刻 t における、この行のHTML表現を生成"""
if not self.is_visible(t):
return ""
html_parts = []
# Phase 1: Pre-roll (歌い出し前)
if t < self.segments[0]['start_ms']:
return f'<s p="2">{self.full_text}</s>' # Futureスタイル
# Phase 2: Singing (歌唱中〜歌唱後)
for i, seg in enumerate(self.segments):
seg_start = seg['start_ms']
# この文字(セグメント)の担当終了時間を特定
if i < len(self.segments) - 1:
seg_end = self.segments[i+1]['start_ms']
else:
# 最後の文字は行の表示終了まで
seg_end = self.display_end
text = seg['text']
if not text: continue
if t >= seg_end:
# 既に歌い終わった文字 -> Pastスタイル
html_parts.append(f'<s p="1">{text}</s>')
elif t < seg_start:
# まだ歌っていない文字 -> Futureスタイル
html_parts.append(f'<s p="2">{text}</s>')
else:
# ★今まさに歌っている文字 -> グラデーション計算
duration = seg_end - seg_start
elapsed = t - seg_start
if duration <= 0: duration = 1
# 進行度 (0.0 〜 1.0)
progress = elapsed / duration
if ENABLE_GRADIENT:
# 進行度に応じてペンIDを選択
idx = int(progress * GRADIENT_STEPS)
idx = max(0, min(idx, GRADIENT_STEPS - 1))
pen_id = 10 + idx
html_parts.append(f'<s p="{pen_id}">{text}</s>')
else:
html_parts.append(f'<s p="1">{text}</s>')
return "".join(html_parts)
# --- メイン変換処理 ---
def convert_lrc_to_ytt(lrc_content: str) -> str:
# -------------------------------------------------
# Step 1: LRCファイルのパース
# -------------------------------------------------
lrc_objects = []
lines = lrc_content.strip().split('\n')
for line in lines:
line = line.strip()
if not line: continue
# [mm:ss.xx]タグをすべて抽出
matches = re.findall(r"(\[\d{2}:\d{2}[:\.]\d{2,3}\])([^\[]*)", line)
if not matches: continue
segments = []
for time_tag, text in matches:
ms = parse_lrc_time(time_tag)
segments.append({"start_ms": ms, "text": text})
if segments:
obj = LrcLine(
raw_text="".join([s['text'] for s in segments]),
segments=segments,
line_start_ms=segments[0]['start_ms']
)
lrc_objects.append(obj)
if not lrc_objects:
return ""
# -------------------------------------------------
# Step 2: 表示スケジュールの計算 (単一行ルールの適用)
# -------------------------------------------------
# 「前の行が消えてから次の行を表示する」ための時間を管理
previous_line_end = 0
for i in range(len(lrc_objects)):
obj = lrc_objects[i]
# --- 終了時間の計算 ---
# 基本ルール: 歌い終わり + Post-roll時間
ideal_end = obj.singing_end_ms + POST_ROLL_MS
# 次の行との衝突回避
if i < len(lrc_objects) - 1:
next_start = lrc_objects[i+1].start_ms
# 次の行の歌い出し3秒前までには消したい
cutoff = next_start - GAP_TO_NEXT_MS
# 早い時間を採用
actual_end = min(ideal_end, cutoff)
# ただし、自分が歌っている最中に消えてはいけない
obj.display_end = max(obj.singing_end_ms, actual_end)
else:
obj.display_end = ideal_end
# --- 開始時間の計算 ---
# 基本ルール: 歌い出しの3秒前
ideal_start = obj.start_ms - PRE_ROLL_MS
# 必須ルール: 「前の行の終了時間」より後であること
obj.display_start = max(0, ideal_start, previous_line_end)
# 記録更新
previous_line_end = obj.display_end
# -------------------------------------------------
# Step 3: タイムラインイベントの収集 (Compositor処理)
# -------------------------------------------------
# 画面上の何かが変化する「すべての瞬間」をリストアップ
timeline_events = set()
for obj in lrc_objects:
timeline_events.add(obj.display_start)
timeline_events.add(obj.display_end)
for i, seg in enumerate(obj.segments):
seg_start = seg['start_ms']
timeline_events.add(seg_start)
# セグメント終了点
if i < len(obj.segments) - 1:
seg_end = obj.segments[i+1]['start_ms']
else:
seg_end = obj.display_end
# グラデーション用の中間タイミングを追加
if ENABLE_GRADIENT and i < len(obj.segments):
duration = seg_end - seg_start
# 極端に短い区間は分割しない (負荷軽減)
if duration > 50:
step_ms = duration / GRADIENT_STEPS
for k in range(1, GRADIENT_STEPS):
mid_point = int(seg_start + (k * step_ms))
timeline_events.add(mid_point)
# 時間順にソート
sorted_events = sorted(list(timeline_events))
# -------------------------------------------------
# Step 4: レンダリング (XML生成)
# -------------------------------------------------
xml_body_parts = []
# 時間区間ごとにループ
for i in range(len(sorted_events) - 1):
t_start = sorted_events[i]
t_end = sorted_events[i+1]
duration = t_end - t_start
if duration <= 0: continue
target_html = ""
# この瞬間に表示すべき行を探す (スケジュール済みなので0個か1個)
for obj in lrc_objects:
if obj.is_visible(t_start):
target_html = obj.render_at(t_start)
break
# 字幕がある場合のみ出力
if target_html:
xml_body_parts.append(f'<p t="{t_start}" d="{duration}">{target_html}</p>')
# ヘッダー + ボディ + フッターを結合
return create_ytt_header() + "\n".join(xml_body_parts) + "\n</body>\n</timedtext>"
# --- 実行エントリポイント ---
if __name__ == "__main__":
# コマンドライン引数処理
if len(sys.argv) < 2:
print("【使い方】")
print("このスクリプトに .lrc ファイルをドラッグ&ドロップするか、")
print("コマンドラインから以下のように実行してください。")
print("python lrc_to_ytt.py input.lrc")
else:
input_file = sys.argv[1]
output_file = os.path.splitext(input_file)[0] + ".ytt"
try:
print(f"🔄 読み込み中: {input_file}")
# ※ 文字コードエラーが出る場合は encoding="utf-8" を "cp932" 等に変更してください
with open(input_file, "r", encoding="utf-8") as f:
content = f.read()
print("🔄 変換中...")
result = convert_lrc_to_ytt(content)
with open(output_file, "w", encoding="utf-8") as f:
f.write(result)
print(f"✅ 生成完了: {output_file}")
print("--------------------------------------------------")
print("【YouTubeへのアップロード時の注意】")
print("Studioでの字幕のプレビューは正常でなく、公開しないと確認できないため注意してください。")
print("--------------------------------------------------")
except Exception as e:
print(f"❌ エラーが発生しました: {e}")
print("入力ファイルの文字コードが UTF-8 であるか確認してください。")
input("Enterキーを押して終了...")
まとめ
このツールを使えば、動画編集なしでLRCの歌詞ファイルを直接YouTubeの字幕に表示できます。
RhythmicaLyricsでポチポチ歌詞を作ったら、すぐにYouTubeに載せられるため、一度試してみてください。