動画と歌唱区間を分ける

一つのYouTube動画に複数の歌唱区間が登録されることがあります。このとき再生キューを動画IDだけで表すと、どの区間を選んだのかを区別できません。同じ動画を一つにまとめてしまえば、別々に選んだ区間が失われます。反対に、区間の開始・終了時刻を持ったまま順序を変えたり、任意の行から始めたりできないと、選択した区間へ移る手掛かりがなくなります。

この記事では、この設計上の課題と検証方法を扱います。過去の障害を振り返る記事ではなく、実装資料を読んで小さなサンプルを動かした記録です。

01 選ぶ歌唱区間の行動画ID・開始秒・終了秒
02 並べる再生キュー同じ動画の別区間も残す
03 決める現在の区間選んだ位置の項目を使う

実装で確認できた方式

確認できたソースでは、再生アイテムに動画IDとは別にstartSecondsendSecondsを持たせています。キュー更新時にstartIndexから選択アイテムを解決し、shuffleが有効なら選択アイテムをキューの先頭へ置く実装でした。通常時はqueueの順番を保ち、currentIndex・currentItem・再生状態を更新します。

ソースに対応する単体テストは、アプリ本体の状態更新関数(reducer)を呼び出し、再生状態・queue件数・requestNonce、動画IDと開始・終了時刻の保持、3番目(index 2)を選んだときのcurrentItem/currentIndexを検査しています。テスト自身はreducer状態の補助検証と明記し、画面操作のE2E検証と区別しています。

この記事で動かすコードは本番からの完全転用ではなく、同じ考え方を説明するための記事用の独立サンプルです。特にサンプルは時刻や入力を検証しますが、本番の入力検証の仕様を再現したものではありません。

独立サンプルで確かめる

サンプルはアイテム配列と開始位置を受け取り、キュー、現在位置、現在アイテムを返します。まず各項目のID、動画ID、開始時刻・終了時刻を検査し、開始位置が配列範囲内の整数かも確認します。元の並びと各区間を保ち、同じ動画IDを持つ別区間も別のキュー項目として残すのが目的です。

以下はダウンロードできるsegment-queue.mjsの全内容です。テストファイルも同じフォルダへ保存すると、相対importのまま実行できます。

/**
 * A small, standalone model of selecting a singing segment for a play queue.
 * This is an article example, not production Sheer Clip code.
 */
export function makeSegmentQueue(items, startIndex = 0) {
  if (!Array.isArray(items) || items.length === 0) {
    throw new TypeError('items must be a non-empty array');
  }

  for (const item of items) {
    if (!item || typeof item.id !== 'string' || item.id.length === 0) {
      throw new TypeError('each segment needs an id');
    }
    if (typeof item.youtubeId !== 'string' || item.youtubeId.length === 0) {
      throw new TypeError('each segment needs a youtubeId');
    }
    if (!Number.isFinite(item.startSeconds) || item.startSeconds < 0) {
      throw new RangeError('startSeconds must be a non-negative finite number');
    }
    if (!Number.isFinite(item.endSeconds) || item.endSeconds <= item.startSeconds) {
      throw new RangeError('endSeconds must be greater than startSeconds');
    }
  }

  if (!Number.isInteger(startIndex) || startIndex < 0 || startIndex >= items.length) {
    throw new RangeError('startIndex must point to an item in the queue');
  }

  const queue = items.slice();
  return {
    queue,
    currentIndex: startIndex,
    currentItem: queue[startIndex],
  };
}

サンプルコード segment-queue.mjs を保存 テスト segment-queue.test.mjs を保存

入力例の動画IDは説明用の架空の値です。同じ動画video-abcから15〜38秒と72〜104秒の2区間、別動画video-xyzから9〜27秒を用意し、startIndex = 2を選びます。結果は次のとおりです。

currentIndex: 2
currentItem: { id: 'part-3', youtubeId: 'video-xyz', startSeconds: 9, endSeconds: 27 }

この結果は「3つ目の配列項目を選び、そのアイテムの区間時刻も残す」というサンプルの実行結果です。ブラウザで動画が9秒から再生されることを示すものではありません。

実行結果と再現手順

  1. 2つのファイルを同じフォルダに保存

    segment-queue.mjssegment-queue.test.mjsを一つの作業フォルダへ置きます。

  2. Node.jsの標準テストランナーで実行

    そのフォルダをカレントディレクトリにしてnode --test segment-queue.test.mjsを実行します。

  3. 4つの期待値を確認

    3番目を選ぶとindex 2になること、YouTube IDと時刻が保持されること、同じ動画IDの2区間が別アイテムとして残ること、空配列・範囲外index・長さ0以下の区間が拒否されることを確認します。

Node.js v24.19.0で、独立サンプルのテスト4件が成功しました。これはサンプルコードの実行結果であり、Sheer Clip本体のテスト一式を実行した結果ではありません。

この検証で分かること・残ること

サンプルで分かるのは、配列内のどの項目を現在選択するか、区間時刻を項目データとして保持できるか、同じ動画IDでも別区間を分けられるかという範囲です。YouTube IFrame APIの再生位置設定、再生終了時の区間境界検知、次の動画への自動遷移、ネットワーク状態や埋め込み可否は扱いません。

再生の連続性を確かめるには、テスト用動画の公開状態と埋め込み可否を揃えたブラウザ操作テストが別途必要です。

検証ソースと出典

参照した実装とテストのソーススナップショットはcommit 32c87bcc86913de8e404741a01b02817b2077fc6です。このスナップショットは確認日当日の公開版commit a9077c26a8d276f4081ab8b940572a14d63e8059と異なるため、同じコードがその公開版へそのまま含まれるとは確認していません。

  • apps/web/src/lib/player/types.ts:11-20PlaybackQueueItemの動画ID・区間時刻・sourceType。
  • apps/web/src/lib/player/playback-store.tsx:112-142REPLACE_QUEUEでの開始位置・current item・再生状態の更新。
  • tests/unit/video-timeline-segment-playback.test.ts:38-70,72-102,104-138 — キュー更新、区間フィールド保持、開始位置を検査するreducerテスト。

単体テストはソーススナップショットから読み取ったもので、今回の調査ではそのテストファイル自体を実行していません。ここに掲載したサンプルの4テストのみをローカル実行しました。