はじめに
ブラウザ動画編集アプリのタイムラインに、CapCutのような端スクロール付きトリムを実装しました。
機能は動いていました。しかし実際にマウスと指で触ると、durationは正しいのに操作感が壊れていました。
- 画面端でハンドルが急加速する
- 右へ伸ばす速度と左へ戻す速度が違いすぎる
- ズーム後にポインターとクリップ端がずれる
- モバイルで8秒に置いたはずが、指を離すと約4秒に見える
- 短くしたクリップが表示範囲から消える
- PCの修正が固定再生ヘッド型のモバイルを壊す
この記事では、UXを基準に座標系を分離し、PCとモバイルをそれぞれ正しい操作モデルに直した過程を紹介します。
プロジェクトとGitHubリポジトリ
今回改善したのは、ローカルファーストで動作するブラウザ動画編集アプリ Timeline Studio です。
- GitHub: MartinDelophy/ai-video-editor
- 対象PR: Improve timeline trim auto-scroll — PR #40
- オンラインデモ: Timeline Studio
Visuals、字幕、ステッカー、ボイスオーバー、動画原音、BGM、オーバーレイを同じタイムライン上で編集できます。PCは精密操作向け、モバイルはタッチ中心・固定再生ヘッド型です。
根本原因: 異なる座標系を混ぜていた
トリムには、画面上のポインター座標、scrollLeft、レンダリングされたピクセル座標、タイムライン上の秒数、モバイルの480px/520px基準幅、PCの実ビューポート幅が登場します。
見た目の症状は違っても、原因は共通でした。
異なる座標系を、同じ値として扱っていた。
UXの不変条件
- ハンドルは常にポインターまたは指の近くにある
- 1回のジェスチャー中は1秒あたりのピクセル数を変えない
- 自動スクロールを二重のトリム量にしない
- ルーラー、クリップ、ハンドルは同じスケールを参照する
- 戻す操作はオーバーシュートを修正しやすい速度にする
- ハンドルを画面外へ完全に消さない
- モバイルで指を離したあと、固定再生ヘッドの時刻を変えない
速度定数ではなく、この観察可能な不変条件を基準に修正しました。
1. ポインター移動とスクロール移動を分離する
export function getTimelineDragTimeDelta({
clientX, startX, scrollOffset = 0,
contentWidth, timelineDuration,
}) {
if (contentWidth <= 0) return 0;
return ((clientX - startX + scrollOffset) / contentWidth)
* timelineDuration;
}
scrollOffsetへ入れるのは明示的な端スクロールだけです。ブラウザの上限補正まで含めると、補正が次フレームで追加のトリム量になり、戻すほど加速する正のフィードバックが発生します。
2. 端スクロールの速度をUXで決める
端の48pxを自動スクロール領域とし、距離に応じて二次曲線で速度を上げます。
const strength = clamp(
(threshold - distanceFromEdge) / threshold, 0, 1,
);
const step = Math.max(0.35, strength ** 2 * maxStep);
右へ延長する最大速度は14px/frame、左へ戻る最大速度は6px/frameです。延長時は速度、戻す時は精度を優先しました。
3. ジェスチャー中のpixels-per-secondを固定する
総時間が伸びるたびにトラックを100%幅へ圧縮すると、1秒あたりの幅が変わり、ハンドルが遅れて見えます。
export function getTrimLockedTrackWidth(
timelineDuration, pixelsPerSecond,
) {
return Math.max(0, timelineDuration)
* Math.max(0, pixelsPerSecond);
}
クリップ、ルーラー、スナップガイド、時間換算が同じスケールを参照するようにしました。
4. モバイルの8秒が4秒になる問題
モバイルは実表示幅ではなく480pxまたは520pxの基準トラック幅を使います。終了時だけ狭い実ビューポートでズームを戻したため、8秒が約4秒へ再マッピングされていました。
export function getTimelineVisibleDurationForPixelScale(
pixelsPerSecond, scaleBasisWidth,
) {
return pixelsPerSecond > 0
? scaleBasisWidth / pixelsPerSecond
: 0;
}
PCでは実ビューポート幅、モバイルでは480px/520pxを入力します。数式は共有し、正しい基準をプラットフォームごとに選びます。
5. ハンドルを画面外へ消さない
端スクロール判定には生のポインター座標を使い、時間換算に使う座標だけをビューポート内側へ制限します。
export function getTimelineTrimDragClientX(
clientX, rect, inset = 10,
) {
const safeInset = Math.min(
rect.width / 2, Math.max(0, inset),
);
return clamp(
clientX, rect.left + safeInset, rect.right - safeInset,
);
}
画面外へドラッグしても自動スクロールは続きますが、見えるハンドルは内側10pxに残ります。これはPCとモバイルの両方に適用しました。
PCとモバイルで分けた処理
| 項目 | PC | モバイル |
|---|---|---|
| スケール基準 | 実ビューポート幅 | 480px/520px |
| 再生ヘッド | ドラッグ可能 | 中央固定 |
| 前後余白 | 通常スクロール | 同じ幅の前後gutter |
| リリース時 | desktop用spacerを整理 | 固定再生ヘッドが末尾を超えないよう制限 |
レスポンシブ対応は同じコードを通すことではなく、それぞれの入力モデルで同じユーザー上の約束を守ることだと分かりました。
回帰テスト
ユーザーフィードバックをテスト可能な不変条件へ変換しました。
- 右方向と左方向の最大速度
- スクロール変位が二重にトリム量へ入らないこと
- 長いプロジェクトでもルーラー数値が見えること
- モバイルの520px基準で8秒が8秒のまま残ること
- モバイルだけがリリース位置を最終端へ制限すること
- PCとモバイルのハンドルが安全インセット内に残ること
関連テスト35件、TypeScriptチェック、production buildを通過させました。
まとめ
state上のdurationが正しくても、指とハンドルが40px離れていれば、ユーザーにとっては壊れています。ブラウザの仕様どおりでも、指を離した瞬間にクリップが消えれば操作を信頼できません。
ユーザーはpointer event、React state、scrollLeft、CSS widthを別々に体験しません。ひとつの連続した物理操作として体験します。
重要だったのは14px/frameの自動スクロールではなく、クリップ、ルーラー、スクロール、ズーム確定、モバイルのリリース処理、テストのすべてが、このジェスチャーの意味に合意したことでした。
機能を実装することと、信頼できる操作を実装することは違います。