@event/session-card の engine/ は React 非依存の純関数群である。3リポに4コピーあった balance engine(最多重複コード)をここに一本化した。移植は ivs-cms cardLayouts.tsx の数式・分岐を同一に保つ関数化であり、JSDoc にも「数式・分岐は同一」と明記している — つまり ivs の実カードとの挙動差分ゼロが移植の受け入れ条件である(resolveTitleLines / resolveRoleLines は描画側と同一式を共有する切り出しそのもの)。
設計原則(1920×1080 master frame)
- 写真をぎちぎちまで大きく取る(photo-first balance)
- タイトルは1行が基本、相当長い場合のみ2行
- 名前は省略しない(overlap "name plate" でフレーム外まで許容)
- 役職は短ければ大きく1〜2行、長ければフォントを下げて最大3〜4行で全文表示(予約高は据え置きなので写真サイズは不変)
variant は "1A".."10A"(数字 = 並べる登壇者数)。保存された cardVariant が人数より少ないと写真が黙って欠けるため、effectiveVariant(cardVariant, speakerCount) が最低でも人数まで枠を自動補正する(明示的に多い枠は尊重して下げない)。
visualWidth — 幅の通貨
全ての fit 計算は px 実測ではなく概算幅 visualWidth(CJK = 1.0 / ASCII = 0.55 em)で行う。これにより server / client / satori のどこでも同じ答えが出る(決定論)。CHAR_WIDTH_MULT = 1.0 は「CJK は palt + letter-spacing 詰めでも1文字 ≒ 1em、ASCII は visualWidth 側で 0.55 に圧縮済み」という前提の係数である。
computeBalance — {titleSize, titleLines, photoSize, gap} を解く
上タイトル / 下スピーカー構成(1A–4A 系)の配分ソルバ。可用高 available = H - TICKER_H - METAROW_H - FOOTER_AREA - topPad の中で、写真を大きい方から 10px 刻みで下げながら探索する:
- Phase A: 1行 + 基準フォントのまま入る最大写真
- Phase B: 1行のままフォントを縮小(下限
minTitleSize) - Phase C+: L = 2..maxTitleLines。長文は「2行に小さく詰め込む / 切る」のではなく行数を増やして読めるフォントを保つ。中間の L では下限フォントを死守し、届かない長文は次の行数へ回す。最終 L だけ幅頭打ちを許容して必ず収める
- Fallback: maxTitleLines 行 × 最小フォント(切らない)
英語(Latin)タイトルは字面が大きく見えるため基準フォントを 0.8 倍に抑える補正が入る。computeGridBalance は同じ Phase 探索を rows × cols グリッド(5A–10A)に拡張し、カラム幅 colW = (contentWidth - (cols-1)*colGap) / cols を写真サイズの上限 cap にする。6A–10A は DENSE_DEFAULTS(maxTitleLines: 2、minPhotoSize: 240 等)を使う。
breakTitleLines — 決定論的改行
text-wrap: balance + word-break: auto-phrase 任せでは CJK が文節途中で割れて不安定だった。ここでは改行候補を品質ペナルティ付きで列挙し、DP で「行長の均し(raggedness)+ 境界ペナルティ」を最小化する:
| 境界 | penalty |
|---|---|
| スペース区切り | 0 |
| 句読点直後(、。・〜 等) | 2 |
| 閉じ括弧直後 / 開き括弧直前 | 3 |
| 文節(任意の CJK 境界、最後の手段) | 9 |
禁則も守る: 行頭に閉じ括弧・句読点・小書き仮名・長音を置かない、行末を開き括弧で終えない、英単語の途中で割らない。PENALTY_CLEAN = 3(良い境界のみ)と PENALTY_PHRASE = 9(文節も許可)の2段があり、fitTitleLayout は「clean で割れる最大フォント → phrase 許可 → 面積 fit」の順に落ちる。
役職と名前の fit
fitRole— 予約高(fsRoleMax × 1.25 × 3 行)内で、clean 境界 → phrase 境界の2パスで割れる最大フォントを探す。「代表取締役」を「代表取 / 締役」と語中で割らないuniformRoleSize— カード内全登壇者のfitRole結果の最小値を採り、役職フォントを1サイズに統一する(一番長い役職に全員を合わせる)uniformNameSize— 英語(Latin)名のみを1サイズに統一。CJK 名は個別 fit のままfitSpeakerName— 1行優先(floor = 基準の 0.42 倍まで縮小して1行維持)、それでも入らない長名のみ中黒 / スペース境界で2行に折る。nameScales[id]の per-speaker 手動倍率が最後に掛かるfitSpeakerRole— 手動上書き(\n明示改行)> 統一サイズでの自動改行 > 個別 auto-fit の優先順。上書きはページ言語と一致するときだけ採用する(overrideForLang)
これらは第19章のデザイナーが onResolved 経由でそのまま参照する。engine の数値(W=1920 / TICKER_H=80 / TITLE_LINE_RESERVE=1.04 等)は engine/constants.ts に集約されており、変更はレイアウト全体に波及するため原則触らない。