LINE配信動画の「完視聴」をどう測るか — ネイティブイベントの限界と視聴カバー率の実装
LINE で動画を配信するとき、再生手段の選択(動画メッセージ / Flex / LIFF)が計測の上限を決めます。ネイティブの完視聴イベントは過大・過小の両方向にノイズがあり、精度が必要なら LIFF での自前計測しかありません。「通過区間の和集合」で測る視聴カバー率の実装と分母設計まで、一次実装で示します。
by Akisame
LINE で配信した動画は、ちゃんと見られているのか。答えを先に言うと、測り方を決める前に再生手段を決めてしまうと、計測の上限がそこで固定されます。ネイティブの完視聴イベントは過大・過小の両方向にノイズがあり、視聴の精度が要るなら LIFF での自前計測が実質的に唯一の選択肢です。本記事では、3つの再生手段の計測自由度を比較し、「90%位置通過」でも「累積再生時間」でもない**視聴カバー率(通過区間の和集合)**の実装を、動くコードで示します。
前提として、筆者は実運用の LINE 公式アカウントで動画配信の計測を実装・運用しています。本文の数値はすべてレンジ表記です(個別の事業が特定できる実値は出しません)。
再生手段が計測の上限を決める — 3つの選択肢
LINE のトーク内でユーザーに動画を見せる手段は、実質3つです。UX・実装コスト・取れるイベントがまったく違います。
| 手段 | UX | 実装コスト | 取れる計測 |
|---|---|---|---|
動画メッセージ(trackingId 付き) | トーク内でそのまま再生。摩擦最小 | 最小 | 完視聴イベントのみ(1:1 限定・後述の制約あり) |
| Flex Message の動画 | カード内再生。レイアウト自由 | 小 | ほぼゼロ(trackingId を指定できず完視聴イベント自体が発生しない) |
LIFF + 自前 video 要素 | タップして LIFF ブラウザが開く(1タップの摩擦) | 大 | ほぼ全部(再生開始・シーク・速度・通過区間・ユーザー識別) |
ここで効いてくるのが、イベント設計の回で書いた原則——計測は実装のあとから足せない——です。「とりあえず動画メッセージで配信して、様子を見て計測を厚くする」は成立しません。動画メッセージを選んだ時点で、取れるものは完視聴イベントだけに固定されます。逆に「見た目のリッチさ」で Flex の動画を選ぶと、計測はゼロに固定されます。再生手段の選定は UX の意思決定であると同時に、計測の上限の意思決定です。
ネイティブ完視聴イベントの実力と限界
動画メッセージに trackingId を付けて送ると、ユーザーが動画を見終えたときに webhook で完視聴イベント(videoPlayComplete)が届きます。公式の定義は「指定した trackingId 付きで送られた動画を、ユーザーが少なくとも1回見終えたときのイベント」です。実装ゼロで「見終えた」シグナルが取れるのは魅力ですが、KPI に据える前に限界を正確に知っておく必要があります。
取れないもの・ズレるものを並べます。
- 再生開始が取れない。 完視聴イベントはあっても再生開始イベントは存在しないため、「再生した人のうち何%が見終えたか」という率が作れません。分子だけあって分母がない状態です。
- 視聴回数を表さない(公式明記)。同じセッション内で何度見ても重複発火せず、トークルームを閉じて開き直すと再発火することがあります。回数集計には使えません。
- 「見終えた」の閾値が非公開。 どの再生位置・条件で発火するかは公開されていません。さらに手元の実運用では、シークバーで終端付近へ飛ばした場合にも発火することを確認しています。つまり「10秒だけ触って終端へ飛ばした人」が完視聴にカウントされ得ます。
- 1:1 トーク限定(前節のとおり)。
まとめると、ネイティブの「完視聴数」は過大方向(シーク飛ばしでも発火・閾値不明)と過小方向(分母が無い・回数が数えられない・グループでは取れない)のノイズが混ざった数字です。「配信した動画の完視聴率が 40%」といった報告は、この仕様を知らずに読むと確実に誤読します。分母のズレが生む誤読の一般論は静かに壊れる計測の回に書きました。
視聴カバー率 — 「通過区間の和集合」で測る
精度が要る場合、LIFF で自前の video 要素を持てば再ローイベントを直接観測できます。問題は何を「見た」と定義するかです。よくある2方式には、それぞれ明確な穴があります。
- 90%位置通過方式(再生ヘッドが動画の90%地点を通過したら完視聴): シークで終端へ飛ばすと通過扱いになり、ネイティブイベントと同じ過大の穴が残ります。
- 累積再生時間方式(再生していた時間の合計が動画長の90%を超えたら完視聴): 倍速視聴だと実時間が半分になり過小評価。逆に、同じ10秒を9回繰り返し見ただけでも「90%視聴」になる過大評価も同居します。
正解は、再生ヘッドが実際に通過した区間の和集合を取り、その合計長を動画長で割ることです。これを視聴カバー率と呼んでいます。シークで飛ばした区間は和集合に入らず、倍速で通過した区間は正しく入り、同じ区間を何度見ても1回分にしかなりません。過大と過小の両方を同時に塞げる定義です。
実装は2段です。まず、timeupdate のサンプル列から「実再生で通過した区間」だけを拾い、区間をマージします。
// 視聴カバー率: 再生ヘッドが通過した区間の和集合 ÷ 動画長
type Interval = { start: number; end: number }
/** 区間の配列をソートしてマージし、重なりのない和集合にする */
export function mergeIntervals(intervals: Interval[], epsilon = 0.25): Interval[] {
const sorted = [...intervals].sort((a, b) => a.start - b.start)
const merged: Interval[] = []
for (const cur of sorted) {
const last = merged[merged.length - 1]
if (last && cur.start <= last.end + epsilon) {
// 重なり or 誤差範囲で隣接 → 1本に伸ばす
last.end = Math.max(last.end, cur.end)
} else {
merged.push({ ...cur })
}
}
return merged
}
/** 視聴カバー率(0〜1)。何度見ても同じ区間は1回分にしか数えない */
export function coverageRatio(intervals: Interval[], duration: number): number {
if (duration <= 0) return 0
const covered = mergeIntervals(intervals).reduce(
(sum, { start, end }) => sum + (end - start),
0,
)
return Math.min(covered / duration, 1)
}
/**
* timeupdate のサンプル列から「実再生で通過した区間」だけを拾うレコーダー。
* - 連続再生: 前回サンプルとの差が(timeupdate 間隔 × 再生速度)の想定内 → 区間として採用
* - シーク: 差が想定を超える → 区間にしない(シーク飛ばしの水増しを防ぐ)
* - 倍速: maxStep が playbackRate に比例するので、倍速の通過も正しく数える
*/
export class WatchedSegments {
private segments: Interval[] = []
private lastTime: number | null = null
// timeupdate はブラウザ実装でおおむね 250ms〜1s 間隔。余裕をみて 1.5s を基準にする
constructor(private baseStepSec = 1.5) {}
onTimeUpdate(currentTime: number, playbackRate = 1): void {
if (this.lastTime !== null) {
const delta = currentTime - this.lastTime
const maxStep = this.baseStepSec * Math.max(playbackRate, 1)
if (delta > 0 && delta <= maxStep) {
this.segments.push({ start: this.lastTime, end: currentTime })
}
// delta が負(巻き戻し)や maxStep 超(前方シーク)は区間にしない
}
this.lastTime = currentTime
}
/** seeking / pause / ended では連続性を切る(次のサンプルを起点にし直す) */
breakContinuity(): void {
this.lastTime = null
}
snapshot(duration: number): { coverage: number; segments: Interval[] } {
const segments = mergeIntervals(this.segments)
return { coverage: coverageRatio(segments, duration), segments }
}
}このコードは検証済みです(0→5秒再生後に55秒へシークして最後まで見たケースで、カバー率は 10/60——シークで飛ばした45秒分は入らない。2倍速で全編通過したケースでカバー率 1.0)。閾値(baseStepSec)は「timeupdate の発火間隔より十分大きく、意図的なシークより十分小さい」帯に置きます。0.25〜0.5秒間隔で発火する環境なら 1.5 秒で両側に余裕があります。
「完視聴」の定義は、このカバー率の上に事後的に引けます。カバー率 0.9 以上を完視聴と呼ぶか 0.95 にするかは集計側の判断であって、計測の実装を変える必要がありません。区間データをそのまま送っておけば、定義変更に対して遡及可能です——これもイベント設計の回の「ローイベントを残し、解釈は後段で」の各論です。
LIFF 実装 — 配信からイベント送信まで
配信フローは「メッセージ(画像やボタン)→ タップ → LIFF アプリが開く → 自前 video で再生」です。LIFF 側の配線はレコーダーを video 要素につなぎ、スナップショットを定期送信するだけです。
// LIFF プレーヤー側: video 要素に WatchedSegments を配線し、定期スナップショットを送る
function track(video: HTMLVideoElement): void {
const watched = new WatchedSegments()
video.addEventListener('timeupdate', () => {
watched.onTimeUpdate(video.currentTime, video.playbackRate)
})
// シーク・一時停止・終了では連続性を切る(飛んだ区間を「見た」ことにしない)
for (const ev of ['seeking', 'pause', 'ended'] as const) {
video.addEventListener(ev, () => watched.breakContinuity())
}
const send = () => {
const { coverage, segments } = watched.snapshot(video.duration)
const payload = JSON.stringify({ videoId, lineUserId, coverage, segments, at: Date.now() })
// 離脱時にも届くよう sendBeacon で送る
navigator.sendBeacon('/api/video-progress', payload)
}
// 5秒ごと+タブ非表示(離脱の実質的な検知点)で送信
setInterval(send, 5_000)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') send()
})
}運用上のポイントを3つ。
- 動画の置き場所はエグレス(転送量)課金の無いオブジェクトストレージで足ります(筆者は Cloudflare R2 を使っています)。トーク経由の視聴は同時視聴数が限られるので、HLS のようなストリーミング基盤は不要で、mp4 の直接配信+HTTP Range リクエストで実用になります。動画計測のために配信基盤へ投資が要る、というのは思い込みでした。
- ユーザー識別は LIFF のログイン後に
liff.getProfile()で取れるuserIdを使います。これは公式アカウントの webhook に届くuserIdと同一なので、配信ログと視聴ログを同じキーで結合できます。ここが GA 系の匿名計測との決定的な違いで、「誰に配信して誰が見たか」を配信単位で閉じられます。 - 同意の扱いを飛ばさないこと。視聴ログは個人に紐づく行動データなので、LIFF 初回起動時の同意取得(またはプライバシーポリシーへの明示)とセットで設計します。計測と同意の回の原則がそのまま当てはまります。
分母の設計 — 起動率・再生率・完視聴率を段階で読む
LIFF 方式の計測にも構造的な制約が1つあります。メッセージのタップと LIFF の起動は、計測上ほぼ同一点に潰れます。タップ自体はユーザーの端末内の出来事で、こちらから観測できる最初の点は LIFF アプリの初期化(liff.init 完了)だからです。この制約を前提に、率を段階で定義します。
| 指標 | 分母 | 分子 |
|---|---|---|
| 起動率 | 配信数(または開封数) | LIFF 初期化完了 |
| 再生開始率 | LIFF 初期化完了 | play 発火 |
| 視聴カバー率の分布 | 再生開始 | カバー率(0〜1 の連続値) |
| 完視聴率 | 再生開始 | カバー率 0.9 以上(定義は事後選択) |
大事なのは、それぞれの率の分母が1つ上の段の分子であることです。「完視聴率」を配信数比で言うのか再生開始比で言うのかで数字は数倍変わります。実運用のレンジでは、起動率(配信比)は 30〜60%、再生開始率(起動比)は 70〜90%、完視聴率(再生開始比・カバー率0.9基準)は 20〜50% の帯で動いています。段を混ぜた「完視聴率 15%」のような一枚数字にすると、どの段の改善余地が大きいかが見えなくなります。
動画への入口がリッチメニューの場合、2026年7月からリッチメニューの統計情報(表示回数・ユニークユーザー・タップ領域別のクリック)が Messaging API で取得できるようになりました。従来は管理画面(LINE Official Account Manager)で作ったリッチメニューしか統計を確認できませんでしたが、API 作成分も取れるようになったため、「リッチメニュー表示 → クリック → LIFF 起動 → 再生 → カバー率」の段間 CVR を API だけで組めます。なお期間内のユニーククリックが20人未満だと統計が返らない(プライバシー保護)点と、レート制限(60リクエスト/時)には注意してください。
まとめ — どこまで測るべきかの判断
最後に、全配信に LIFF 計測を入れるべきかというと、入れるべきではありません。1タップの摩擦と実装・運用コストがかかる以上、これは過剰計測になり得ます。使い分けの目安を置いておきます。
- ネイティブ(動画メッセージ+完視聴イベント)で足りる場面: 同一形式の相対比較(サムネ A/B、尺 A/B)、傾向のモニタリング。絶対値を主張しない用途。
- LIFF 自前計測が要る場面: 動画の視聴が後段の成果(申込・購入)に効いているかを個人単位で結合して検証したいとき。視聴の絶対量・離脱位置を意思決定に使うとき。教材・オンボーディングなど「どこまで見たか」自体がプロダクト価値のとき。
「測れるものをすべて測る」ではなく、成果とその代理指標の関係から逆算して、どの段の数字が意思決定を変えるかで計測の厚みを決める——これが本連載の一貫した姿勢です。視聴データを配信の出し分けにつなぐ設計は出し分けと holdout の回へ、LINE ファネル全体を壊れにくく組む設計は姉妹編のLINE ファネルの信頼性設計へ続きます。
章末チェックリスト
- 再生手段(動画メッセージ / Flex / LIFF)を、UX ではなく取れる計測から選んだ
- ネイティブ完視聴イベントの制約(1:1 限定・再生開始なし・回数を表さない・閾値非公開)を理解して使っている
- 「完視聴」を位置通過や累積時間ではなく、**通過区間の和集合(視聴カバー率)**で定義した
- 区間データをそのまま送り、完視聴の閾値は集計側で事後選択できるようにした
- 起動率・再生開始率・完視聴率の分母を段階で分け、段を混ぜた一枚数字にしていない
- 視聴ログとユーザー識別(userId)の結合について、同意の設計を済ませた
- そもそもこの配信に LIFF 計測が必要か(ネイティブで足りないか)を最初に問うた