<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>ASS字幕 on 思いつきそうで思いつかなくていたときに</title><link>https://blog.fuga.jp/tags/ass%E5%AD%97%E5%B9%95/</link><description>Recent content in ASS字幕 on 思いつきそうで思いつかなくていたときに</description><generator>Hugo -- gohugo.io</generator><language>ja</language><copyright>Copyright(c) 2022-2025 SATO Daisuke. All rights reserved.</copyright><lastBuildDate>Wed, 15 Jul 2026 05:05:00 +0900</lastBuildDate><atom:link href="https://blog.fuga.jp/tags/ass%E5%AD%97%E5%B9%95/index.xml" rel="self" type="application/rss+xml"/><item><title>podcast-tool 開発日記 #6 — ASS字幕と表現力</title><link>https://blog.fuga.jp/posts/2026-07-15-podcast-tool-devdiary-06-ass-subtitle-styling/</link><pubDate>Wed, 15 Jul 2026 05:05:00 +0900</pubDate><guid>https://blog.fuga.jp/posts/2026-07-15-podcast-tool-devdiary-06-ass-subtitle-styling/</guid><description>&lt;p>2025年9月23日から24日にかけて、字幕が「情報を伝えるテキスト」から「話者を区別する表現」に変わった。&lt;/p>
&lt;p>前回（第5回）でSRT字幕の焼き込みが動いた。タイミングは合っている。BGMのオフセットも考慮できている。ただ、全話者のセリフが同じ白文字で表示される——ナレーターも、MCも、中の人も、見た目は区別がつかない。この状態を2日間で変えることになる。&lt;/p>
&lt;h2 id="フォントサイズから始まった">&lt;a href="#%e3%83%95%e3%82%a9%e3%83%b3%e3%83%88%e3%82%b5%e3%82%a4%e3%82%ba%e3%81%8b%e3%82%89%e5%a7%8b%e3%81%be%e3%81%a3%e3%81%9f" class="header-anchor">&lt;/a>フォントサイズから始まった
&lt;/h2>&lt;p>字幕の色づけより先に、フォントサイズの問題に手がついていた。&lt;/p>
&lt;p>SRTの段階では固定値（&lt;code>font_size: 32&lt;/code>）をそのまま使っていた。短い台詞ならはみ出さないが、長いセリフは画面に収まらない。特に縦型（YouTube Shorts）の1080x1920フレームでは、横幅が狭いぶん余計に問題になる。&lt;/p>
&lt;p>9月23日、&lt;code>ef18651c&lt;/code>「動的フォントサイズ計算機能の実装」で新しいモジュール &lt;code>subtitle_font_calculator.py&lt;/code>（154行）が生まれた。中心にある考え方はシンプルだ——「台本の中で最も長いセリフが画面幅の90%に収まるフォントサイズ」を計算する。&lt;/p>
&lt;div class="highlight">&lt;div style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#b6a0ff">def&lt;/span> &lt;span style="color:#feacd0">calculate_character_width&lt;/span>(text: &lt;span style="color:#f78fe7">str&lt;/span>, character_width_ratio: &lt;span style="color:#f78fe7">float&lt;/span> &lt;span style="color:#00d3d0">=&lt;/span> &lt;span style="color:#00bcff">0.6&lt;/span>) &lt;span style="color:#00d3d0">-&amp;gt;&lt;/span> &lt;span style="color:#f78fe7">float&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#79a8ff">&amp;#34;&amp;#34;&amp;#34;文字列の表示幅を計算
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#79a8ff">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#79a8ff"> 全角文字=1.0、半角文字=character_width_ratioとして重み付け計算
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#79a8ff"> &amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> width &lt;span style="color:#00d3d0">=&lt;/span> &lt;span style="color:#00bcff">0.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">for&lt;/span> char &lt;span style="color:#00d3d0">in&lt;/span> text:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a8a8a8"># 日本語文字（ひらがな、カタカナ、漢字）や全角記号は幅1.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">if&lt;/span> (&lt;span style="color:#f78fe7">ord&lt;/span>(char) &lt;span style="color:#00d3d0">&amp;gt;=&lt;/span> &lt;span style="color:#00bcff">0x3040&lt;/span> &lt;span style="color:#00d3d0">and&lt;/span> &lt;span style="color:#f78fe7">ord&lt;/span>(char) &lt;span style="color:#00d3d0">&amp;lt;=&lt;/span> &lt;span style="color:#00bcff">0x309F&lt;/span>) &lt;span style="color:#00d3d0">or&lt;/span> \
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (&lt;span style="color:#f78fe7">ord&lt;/span>(char) &lt;span style="color:#00d3d0">&amp;gt;=&lt;/span> &lt;span style="color:#00bcff">0x30A0&lt;/span> &lt;span style="color:#00d3d0">and&lt;/span> &lt;span style="color:#f78fe7">ord&lt;/span>(char) &lt;span style="color:#00d3d0">&amp;lt;=&lt;/span> &lt;span style="color:#00bcff">0x30FF&lt;/span>) &lt;span style="color:#00d3d0">or&lt;/span> \
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (&lt;span style="color:#f78fe7">ord&lt;/span>(char) &lt;span style="color:#00d3d0">&amp;gt;=&lt;/span> &lt;span style="color:#00bcff">0x4E00&lt;/span> &lt;span style="color:#00d3d0">and&lt;/span> &lt;span style="color:#f78fe7">ord&lt;/span>(char) &lt;span style="color:#00d3d0">&amp;lt;=&lt;/span> &lt;span style="color:#00bcff">0x9FAF&lt;/span>) &lt;span style="color:#00d3d0">or&lt;/span> \
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (&lt;span style="color:#f78fe7">ord&lt;/span>(char) &lt;span style="color:#00d3d0">&amp;gt;=&lt;/span> &lt;span style="color:#00bcff">0xFF00&lt;/span> &lt;span style="color:#00d3d0">and&lt;/span> &lt;span style="color:#f78fe7">ord&lt;/span>(char) &lt;span style="color:#00d3d0">&amp;lt;=&lt;/span> &lt;span style="color:#00bcff">0xFFEF&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> width &lt;span style="color:#00d3d0">+=&lt;/span> &lt;span style="color:#00bcff">1.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">else&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> width &lt;span style="color:#00d3d0">+=&lt;/span> character_width_ratio
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">return&lt;/span> width
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>日本語の全角文字と英数字の半角文字では表示幅が違う。全角=1.0、半角=0.6として重み付けし、最長行の文字幅を求める。その値と画面幅・高さから逆算して「この幅なら何ピクセルのフォントが適切か」を導き出す仕組みだ。&lt;/p>
&lt;div class="highlight">&lt;div style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#b6a0ff">def&lt;/span> &lt;span style="color:#feacd0">calculate_dynamic_font_size&lt;/span>(script, config, video_width&lt;span style="color:#00d3d0">=&lt;/span>&lt;span style="color:#00bcff">1920&lt;/span>, video_height&lt;span style="color:#00d3d0">=&lt;/span>&lt;span style="color:#00bcff">1080&lt;/span>) &lt;span style="color:#00d3d0">-&amp;gt;&lt;/span> &lt;span style="color:#f78fe7">int&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a8a8a8"># 最長表示行を検出&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> longest_line, max_character_width &lt;span style="color:#00d3d0">=&lt;/span> find_longest_display_line(script)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a8a8a8"># 利用可能な画面領域&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> available_width &lt;span style="color:#00d3d0">=&lt;/span> video_width &lt;span style="color:#00d3d0">*&lt;/span> (screen_width_percentage &lt;span style="color:#00d3d0">/&lt;/span> &lt;span style="color:#00bcff">100.0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a8a8a8"># フォント1文字あたりの概算ピクセル幅 = フォントサイズ * 0.7&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> font_size_by_width &lt;span style="color:#00d3d0">=&lt;/span> available_width &lt;span style="color:#00d3d0">/&lt;/span> (max_character_width &lt;span style="color:#00d3d0">*&lt;/span> &lt;span style="color:#00bcff">0.7&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a8a8a8"># 高さ制約（最大3行表示として計算）&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> font_size_by_height &lt;span style="color:#00d3d0">=&lt;/span> available_height &lt;span style="color:#00d3d0">/&lt;/span> (max_lines &lt;span style="color:#00d3d0">*&lt;/span> &lt;span style="color:#00bcff">1.2&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a8a8a8"># 制約の厳しい方を採用し、min/maxで丸める&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">return&lt;/span> &lt;span style="color:#f78fe7">int&lt;/span>(&lt;span style="color:#f78fe7">max&lt;/span>(min_font_size, &lt;span style="color:#f78fe7">min&lt;/span>(max_font_size, &lt;span style="color:#f78fe7">min&lt;/span>(font_size_by_width, font_size_by_height))))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>「幅制約」と「高さ制約」のうち厳しいほうを採用し、設定した最小・最大フォントサイズの範囲に収める。動画の解像度が変わっても正しく追従する。&lt;/p>
&lt;h2 id="ass形式への昇格">&lt;a href="#ass%e5%bd%a2%e5%bc%8f%e3%81%b8%e3%81%ae%e6%98%87%e6%a0%bc" class="header-anchor">&lt;/a>ASS形式への昇格
&lt;/h2>&lt;p>動的フォントサイズの計算が動いたその日の夕方、&lt;code>a8b5e8f5&lt;/code>「焼き込み字幕の色づけ（WIP）」というコミットが入った。SRTから ASS（Advanced SubStation Alpha）への移行の起点だ。&lt;/p>
&lt;p>SRTは &lt;code>HH:MM:SS,mmm --&amp;gt; HH:MM:SS,mmm&lt;/code> の行に続いてテキストを書くだけのシンプルな形式で、スタイリングの自由度がほぼない。一方、ASSは字幕に本格的なスタイル定義を持つ。ファイル内に &lt;code>[V4+ Styles]&lt;/code> セクションがあり、話者ごとにフォント・文字色・縁取り色・配置を個別に設定できる。&lt;/p>
&lt;p>既存の &lt;code>video_audio_merger.py&lt;/code> には &lt;code>_srt_to_ass()&lt;/code> という変換関数が既にあった——SRTを読み込んでASSに変換し、FFmpegで動画に焼き込む流れは作られていた。ただし全話者が同じ &amp;ldquo;Default&amp;rdquo; スタイルを使っていた。ここに話者ごとのスタイル定義を追加することが次の課題になった。&lt;/p>
&lt;h2 id="色変換の数学">&lt;a href="#%e8%89%b2%e5%a4%89%e6%8f%9b%e3%81%ae%e6%95%b0%e5%ad%a6" class="header-anchor">&lt;/a>色変換の数学
&lt;/h2>&lt;p>ASSのスタイル定義で厄介なのは色の表現方式だ。HTML/CSSでは赤は &lt;code>#FF0000&lt;/code>（RRGGBB順）だが、ASSは &lt;code>0000FF&lt;/code>（BGR順＝BBGGRR）と逆並びになっている。&lt;/p>
&lt;p>&lt;code>speakers.json&lt;/code> の色設定は素直なHTML形式で書きたい。だから変換関数が必要になった。&lt;/p>
&lt;div class="highlight">&lt;div style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#b6a0ff">def&lt;/span> &lt;span style="color:#feacd0">_format_ass_color&lt;/span>(html_color: &lt;span style="color:#f78fe7">str&lt;/span>) &lt;span style="color:#00d3d0">-&amp;gt;&lt;/span> &lt;span style="color:#f78fe7">str&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#79a8ff">&amp;#34;&amp;#34;&amp;#34;HTML色形式(RRGGBB)をASS BGR形式(BBGGRR)に変換&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> color &lt;span style="color:#00d3d0">=&lt;/span> html_color&lt;span style="color:#00d3d0">.&lt;/span>lstrip(&lt;span style="color:#79a8ff">&amp;#39;#&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">if&lt;/span> &lt;span style="color:#f78fe7">len&lt;/span>(color) &lt;span style="color:#00d3d0">!=&lt;/span> &lt;span style="color:#00bcff">6&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">raise&lt;/span> ValueError(&lt;span style="color:#79a8ff">f&lt;/span>&lt;span style="color:#79a8ff">&amp;#34;Invalid color format: &lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>html_color&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> r &lt;span style="color:#00d3d0">=&lt;/span> color[&lt;span style="color:#00bcff">0&lt;/span>:&lt;span style="color:#00bcff">2&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> g &lt;span style="color:#00d3d0">=&lt;/span> color[&lt;span style="color:#00bcff">2&lt;/span>:&lt;span style="color:#00bcff">4&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> b &lt;span style="color:#00d3d0">=&lt;/span> color[&lt;span style="color:#00bcff">4&lt;/span>:&lt;span style="color:#00bcff">6&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">return&lt;/span> &lt;span style="color:#79a8ff">f&lt;/span>&lt;span style="color:#79a8ff">&amp;#34;&lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>b&lt;span style="color:#79a8ff">}{&lt;/span>g&lt;span style="color:#79a8ff">}{&lt;/span>r&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">&amp;#34;&lt;/span>&lt;span style="color:#00d3d0">.&lt;/span>upper()
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>シンプルに見えるが、これを間違えると色が想定と全く違う見た目になる（赤が青になる）。後のテストで6パターンの変換ケースを網羅的に検証しているのはそのためだ。&lt;/p>
&lt;h2 id="speakersjson-に色が生えた">&lt;a href="#speakersjson-%e3%81%ab%e8%89%b2%e3%81%8c%e7%94%9f%e3%81%88%e3%81%9f" class="header-anchor">&lt;/a>speakers.json に色が生えた
&lt;/h2>&lt;p>これまで &lt;code>speakers.json&lt;/code> は話者名・VOICEVOXの話者ID・読み上げ速度を持つファイルだった。ここに &lt;code>color&lt;/code>（文字色）と &lt;code>outline_color&lt;/code>（縁取り色）が加わった。&lt;/p>
&lt;div class="highlight">&lt;div style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-json" data-lang="json">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;ナレーター&amp;#34;: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;id&amp;#34;: &lt;span style="color:#00bcff">24&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;speed_scale&amp;#34;: &lt;span style="color:#00bcff">1.2&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;color&amp;#34;: &lt;span style="color:#79a8ff">&amp;#34;FFFFFF&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;outline_color&amp;#34;: &lt;span style="color:#79a8ff">&amp;#34;FF0000&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;MC&amp;#34;: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;id&amp;#34;: &lt;span style="color:#00bcff">16&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;speed_scale&amp;#34;: &lt;span style="color:#00bcff">1.38&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;color&amp;#34;: &lt;span style="color:#79a8ff">&amp;#34;FFFF80&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;outline_color&amp;#34;: &lt;span style="color:#79a8ff">&amp;#34;00FF00&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;中の人&amp;#34;: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;id&amp;#34;: &lt;span style="color:#00bcff">3&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;speed_scale&amp;#34;: &lt;span style="color:#00bcff">1.15&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;color&amp;#34;: &lt;span style="color:#79a8ff">&amp;#34;C0FFC0&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#34;outline_color&amp;#34;: &lt;span style="color:#79a8ff">&amp;#34;0000FF&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>このファイルは音声合成の設定でもあり、字幕のデザイン定義でもある。話者の「声のプロファイル」に「見た目のプロファイル」が統合された形だ。&lt;/p>
&lt;p>&lt;code>_srt_to_ass()&lt;/code> の中でこのファイルを読み込み、&lt;code>[V4+ Styles]&lt;/code> セクションに各話者のスタイル行を動的に生成する。&lt;/p>
&lt;div class="highlight">&lt;div style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#b6a0ff">if&lt;/span> speakers:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">for&lt;/span> speaker_name, speaker_config &lt;span style="color:#00d3d0">in&lt;/span> speakers&lt;span style="color:#00d3d0">.&lt;/span>items():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#b6a0ff">if&lt;/span> &lt;span style="color:#79a8ff">&amp;#39;color&amp;#39;&lt;/span> &lt;span style="color:#00d3d0">in&lt;/span> speaker_config &lt;span style="color:#00d3d0">and&lt;/span> &lt;span style="color:#79a8ff">&amp;#39;outline_color&amp;#39;&lt;/span> &lt;span style="color:#00d3d0">in&lt;/span> speaker_config:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> primary_color_ass &lt;span style="color:#00d3d0">=&lt;/span> _format_ass_color(speaker_config[&lt;span style="color:#79a8ff">&amp;#39;color&amp;#39;&lt;/span>])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outline_color_ass &lt;span style="color:#00d3d0">=&lt;/span> _format_ass_color(speaker_config[&lt;span style="color:#79a8ff">&amp;#39;outline_color&amp;#39;&lt;/span>])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> style_line &lt;span style="color:#00d3d0">=&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#79a8ff">f&lt;/span>&lt;span style="color:#79a8ff">&amp;#34;Style: &lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>speaker_name&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">,&lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>font_family&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">,&lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>font_size&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">,&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#79a8ff">f&lt;/span>&lt;span style="color:#79a8ff">&amp;#34;&amp;amp;H00&lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>primary_color_ass&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">,&lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>secondary&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">,&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#79a8ff">f&lt;/span>&lt;span style="color:#79a8ff">&amp;#34;&amp;amp;H00&lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>outline_color_ass&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">,&lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>back&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">,&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#79a8ff">f&lt;/span>&lt;span style="color:#79a8ff">&amp;#34;0,0,0,0,100,100,0,0,1,3,0,&lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>alignment&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">,20,20,&lt;/span>&lt;span style="color:#79a8ff">{&lt;/span>margin_v&lt;span style="color:#79a8ff">}&lt;/span>&lt;span style="color:#79a8ff">,1&lt;/span>&lt;span style="color:#79a8ff">\n&lt;/span>&lt;span style="color:#79a8ff">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> f&lt;span style="color:#00d3d0">.&lt;/span>write(style_line)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>各 &lt;code>Dialogue:&lt;/code> 行には &lt;code>Style&lt;/code> フィールドがあり、そこに話者名を入れることで対応するスタイルが適用される。&lt;/p>
&lt;h2 id="正規化キーという落とし穴">&lt;a href="#%e6%ad%a3%e8%a6%8f%e5%8c%96%e3%82%ad%e3%83%bc%e3%81%a8%e3%81%84%e3%81%86%e8%90%bd%e3%81%a8%e3%81%97%e7%a9%b4" class="header-anchor">&lt;/a>正規化キーという落とし穴
&lt;/h2>&lt;p>ここで一つ問題が出た。&lt;/p>
&lt;p>台本の話者名は &lt;code>&amp;quot;ナレーター: テキスト&amp;quot;&lt;/code> という形式で書かれており、処理中に小文字正規化されてキーとして扱われていた（&lt;code>&amp;quot;ナレーター&amp;quot;&lt;/code> → &lt;code>&amp;quot;ナレーター&amp;quot;&lt;/code>自体は日本語なので変化しないが、英字話者名の場合は &lt;code>&amp;quot;MC&amp;quot;&lt;/code> → &lt;code>&amp;quot;mc&amp;quot;&lt;/code> になる）。一方、スタイル定義には元の話者名（&lt;code>&amp;quot;MC&amp;quot;&lt;/code>）を使わなければならない——ASSの &lt;code>Style:&lt;/code> 行と &lt;code>Dialogue:&lt;/code> 行の &lt;code>Style&lt;/code> フィールドが一致しないとスタイルが適用されない。&lt;/p>
&lt;p>&lt;code>3975d4c8&lt;/code>「Task 1.1実装」で追加されたのが &lt;code>_restore_original_speaker_name()&lt;/code> ヘルパー関数だ。正規化済みのキーから元の話者名を復元する。日本語文字の有無で判定したり、よく使われる英語話者名のパターンを持ったりする実装になっている。&lt;/p>
&lt;div class="highlight">&lt;div style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#fff;background-color:#000;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#b6a0ff">def&lt;/span> &lt;span style="color:#feacd0">_restore_original_speaker_name&lt;/span>(normalized_key: &lt;span style="color:#f78fe7">str&lt;/span>) &lt;span style="color:#00d3d0">-&amp;gt;&lt;/span> &lt;span style="color:#f78fe7">str&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#79a8ff">&amp;#34;&amp;#34;&amp;#34;正規化されたspeaker keyから元の話者名を復元する&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a8a8a8"># 日本語文字が含まれていれば大文字化不要&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a8a8a8"># 英字のみのキーは一般的な形式に変換（mc -&amp;gt; MC など）&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#00d3d0">...&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>こういう「大文字小文字の往復」や「正規化と逆引きのミスマッチ」は、複数の処理段階を経るパイプラインで起きがちな問題だ。単体テストを書いて固めた理由はここにある。&lt;/p>
&lt;h2 id="tddで固める">&lt;a href="#tdd%e3%81%a7%e5%9b%ba%e3%82%81%e3%82%8b" class="header-anchor">&lt;/a>TDDで固める
&lt;/h2>&lt;p>9月24日、実装と並行してテストが書かれた。&lt;code>tests/test_task_1_2_style_generation.py&lt;/code>（113行）が新規追加されている。&lt;/p>
&lt;p>色変換の正確性確認（&lt;code>_format_ass_color(&amp;quot;FF0000&amp;quot;) == &amp;quot;0000FF&amp;quot;&lt;/code> など6パターン）、speaker-specific styleが正しいASSフォーマットで生成されるかの確認、エラーハンドリングの確認——実際の出力ファイル &lt;code>sample/output.auto.ass&lt;/code> での統合テストも含めて、全テストケースで動作確認済みとなったのが &lt;code>96eae720&lt;/code>「ASS字幕話者別スタイリング機能の実装完了」だ。&lt;/p>
&lt;p>コミットメッセージに「✅ sample/output.auto.assで実動作確認完了」と書いてある。最終的に生成されたASSファイルの &lt;code>[V4+ Styles]&lt;/code> セクションに &lt;code>MC&lt;/code> と &lt;code>ナレーター&lt;/code> それぞれのスタイル行が現れ、各 &lt;code>Dialogue:&lt;/code> 行のスタイルフィールドが正しく話者名を参照していることが確認された。&lt;/p>
&lt;h2 id="2日間で積み上がったもの">&lt;a href="#2%e6%97%a5%e9%96%93%e3%81%a7%e7%a9%8d%e3%81%bf%e4%b8%8a%e3%81%8c%e3%81%a3%e3%81%9f%e3%82%82%e3%81%ae" class="header-anchor">&lt;/a>2日間で積み上がったもの
&lt;/h2>&lt;p>9月23〜24日に実装されたものを整理すると3点になる。&lt;/p>
&lt;p>台本から最長セリフを検出して動画解像度に応じたフォントサイズを計算する&lt;strong>動的フォントサイズ計算&lt;/strong>（&lt;code>subtitle_font_calculator.py&lt;/code>、154行）。HTML色形式からASS BGR形式への色変換を含む、&lt;strong>話者別ASS V4+スタイルの動的生成&lt;/strong>。そして正規化キーからの話者名復元を含む&lt;strong>スタイルルックアップの修正&lt;/strong>。&lt;/p>
&lt;p>&lt;code>speakers.json&lt;/code> に2フィールド追加するだけで、話者ごとに色が変わる字幕が動画に入るようになった。視聴者がセリフを読まなくても「今画面の色からMCが話している」と分かる。字幕が単なる情報ではなく、演出になった瞬間だ。&lt;/p>
&lt;h2 id="次回予告">&lt;a href="#%e6%ac%a1%e5%9b%9e%e4%ba%88%e5%91%8a" class="header-anchor">&lt;/a>次回予告
&lt;/h2>&lt;p>第7回は9月末から10月にかけての「近代化リファクタリング」へ進む。&lt;code>ffmpeg_runner.py&lt;/code> によるFFmpeg実行の一元化、&lt;code>core/models/audio/video/utils&lt;/code> パッケージへの分割、&lt;code>ruff&lt;/code>・&lt;code>mypy&lt;/code>・&lt;code>pre-commit&lt;/code> の導入——第1回で「単一ファイル295行」だった &lt;code>main.py&lt;/code> の面影がほぼ消えていく。&lt;/p>
&lt;hr>
&lt;p>&lt;em>この記事は &lt;code>podcast-tool&lt;/code> のコミット履歴を一次資料として書いています。引用したコミットハッシュ・時刻・コード構成は当時のリポジトリ状態に基づきます。&lt;/em>&lt;/p></description></item></channel></rss>