0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

動画アップロードが100%で止まるように見える問題を、状態分離で直す

0
Posted at

動画アップロード機能では、進捗表示が100%になった直後に画面が長時間変わらず、利用者から「固まった」「失敗した」と判断されることがあります。

多くの場合、100%が示しているのはブラウザからサーバーへの送信完了であり、動画の検証、ジョブ作成、変換、保存まで終わったわけではありません。

この記事では、アップロードと非同期処理を別の状態として設計し、利用者に現在地を正しく伝える方法を整理します。

100%が何の完了率かを定義する

最初に、処理全体を分けます。

ファイル選択
  ↓
ブラウザ側の事前確認
  ↓
アップロード
  ↓
サーバー側の実ファイル検証
  ↓
処理ジョブ作成
  ↓
変換・圧縮・切り出し
  ↓
結果保存
  ↓
ダウンロード可能

XHRやアップロードSDKが返す進捗率は、多くの場合「送信したバイト数 / 送信予定のバイト数」です。したがって100%になっても、サーバー側ではこれから検証や変換が始まる場合があります。

画面上で単に「100%」とだけ表示すると、利用者は処理全体が完了したと誤解します。表示は次のように段階を分けます。

  • アップロード中:送信バイト数に基づく進捗
  • ファイルを確認中:形式、容量、再生時間などを検証
  • 処理を準備中:ジョブ作成とキュー登録
  • 動画を処理中:変換や圧縮を実行
  • ダウンロードを準備中:結果を保存してURLを発行
  • 完了:結果を取得可能

状態を型で固定する

UIの文言だけを切り替えるのではなく、状態を明示的に定義します。

type UploadStage =
  | { type: "idle" }
  | { type: "validating-client" }
  | { type: "uploading"; uploaded: number; total: number }
  | { type: "validating-server"; jobId: string }
  | { type: "queued"; jobId: string }
  | { type: "processing"; jobId: string; progress?: number }
  | { type: "finalizing"; jobId: string }
  | { type: "completed"; jobId: string; downloadUrl: string }
  | {
      type: "failed";
      jobId?: string;
      code: string;
      retryable: boolean;
    };

この形にすると、「アップロード率100%なのに処理中」という状態を矛盾なく表現できます。また、エラーがアップロード前、送信中、サーバー検証後、処理中のどこで発生したかを区別できます。

アップロード進捗だけを計測する

ブラウザで送信進捗が必要な場合、XHRの upload.onprogress を利用できます。

function uploadVideo(
  file: File,
  onProgress: (uploaded: number, total: number) => void,
  signal: AbortSignal,
): Promise<{ jobId: string; statusUrl: string }> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/video-jobs");

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        onProgress(event.loaded, event.total);
      }
    };

    xhr.onload = () => {
      if (xhr.status !== 202) {
        reject(new Error(`upload_failed:${xhr.status}`));
        return;
      }

      resolve(JSON.parse(xhr.responseText));
    };

    xhr.onerror = () => reject(new Error("network_error"));
    signal.addEventListener("abort", () => xhr.abort(), { once: true });

    const body = new FormData();
    body.append("file", file);
    xhr.send(body);
  });
}

重要なのは、onProgress の100%を「処理完了」に使わないことです。レスポンスで jobId と状態確認用URLを受け取り、その後はジョブ状態を表示します。

サーバーは202とジョブIDを返す

時間のかかる動画処理をHTTPリクエスト内で待ち続けると、プロキシや実行環境のタイムアウト、ブラウザ再読み込み、回線切断の影響を受けやすくなります。

アップロードと最低限の受付処理が完了したら、サーバーは 202 Accepted とジョブ情報を返します。

{
  "jobId": "job_01J...",
  "status": "validating",
  "statusUrl": "/api/video-jobs/job_01J..."
}

状態取得APIは、クライアントが表示に必要な範囲だけを返します。

{
  "jobId": "job_01J...",
  "status": "processing",
  "progress": 42,
  "messageCode": "VIDEO_PROCESSING"
}

内部のコマンド、保存先、例外スタックなどをそのまま公開する必要はありません。利用者向けの状態コードと、運用調査用ログを分けます。

拡張子ではなく実ファイルを検証する

クライアント側の file.type や拡張子は、早いフィードバックには使えますが、サーバー側の信頼できる検証にはなりません。

サーバー側では少なくとも次を確認します。

  • 実際のファイル形式とストリーム情報
  • 映像・音声ストリームの有無
  • ファイル容量
  • 再生時間
  • 幅と高さ
  • 対応コーデック
  • ユーザーまたはセッションの処理上限
  • ジョブを取得しようとしている主体が作成者と一致するか

再生時間やストリームは ffprobe などで確認できます。

ffprobe -v error \
  -show_entries format=format_name,duration,size \
  -show_entries stream=index,codec_type,codec_name,width,height \
  -of json input

検証に時間がかかる場合、画面には「アップロード完了」だけで止めず、「ファイルを確認中」と表示します。

ポーリングには終了条件を持たせる

単純な状態確認でも、無限に同じ間隔で問い合わせないようにします。

async function waitForJob(
  statusUrl: string,
  signal: AbortSignal,
): Promise<JobStatus> {
  const startedAt = Date.now();
  let interval = 1000;

  while (!signal.aborted) {
    if (Date.now() - startedAt > 20 * 60 * 1000) {
      throw new Error("job_status_timeout");
    }

    const response = await fetch(statusUrl, {
      signal,
      credentials: "include",
    });

    if (!response.ok) {
      throw new Error(`status_failed:${response.status}`);
    }

    const job = await response.json();

    if (job.status === "completed") return job;
    if (job.status === "failed") throw new Error(job.messageCode);

    await new Promise((resolve) => setTimeout(resolve, interval));
    interval = Math.min(Math.round(interval * 1.4), 5000);
  }

  throw new DOMException("Aborted", "AbortError");
}

実装時には、ページが非表示になったときの間隔、ネットワーク再接続、サーバーが返す Retry-After、SSEやWebSocketを使う条件も検討します。

再試行で二重ジョブを作らない

利用者が100%で止まったように見えると、同じファイルを何度も送信します。サーバー側で毎回新しいジョブを作ると、処理コストと上限消費が重複します。

対策として次を組み合わせます。

  • 受付時にidempotency keyを渡す
  • 同じセッション・キーの受付結果を一定時間再利用する
  • 送信ボタンを連打できない状態にする
  • 既存ジョブが生きていれば、その状態画面へ戻す
  • 再試行可能なエラーと、入力を変更すべきエラーを分ける

エラー文は次の行動まで示す

内部コード 利用者向け表示 次の操作
FILE_TOO_LARGE ファイル容量の上限を超えています 短くするか容量を小さくする
DURATION_TOO_LONG 再生時間の上限を超えています 必要な範囲だけ残す
UNSUPPORTED_MEDIA この動画形式を処理できません 別形式で書き出す
UPLOAD_INTERRUPTED アップロードが途中で止まりました 通信を確認して再試行
PROCESSING_FAILED 動画を処理できませんでした 元ファイルを確認して再試行
RATE_LIMITED 短時間の利用上限に達しました 表示された時間後に再試行

「エラーが発生しました」だけでは、同じ操作を繰り返すしかありません。原因カテゴリと次の行動をセットにします。

観測するイベントも状態ごとに分ける

分析では、ページ表示から完了までを一つの成功率にまとめず、段階別に記録します。

file_selected
client_validation_failed
upload_started
upload_completed
server_validation_failed
job_created
processing_started
processing_failed
download_ready
download_started

各イベントには、個人情報やファイル名そのものを送らず、ページ、ロケール、形式カテゴリ、容量帯、再生時間帯、エラーコード、ジョブIDの安全な参照値などを持たせます。

これにより、「送信に失敗している」のか「検証で落ちている」のか「処理時間が長い」のかを分けて改善できます。

実際の画面で確認する

実装後は、小さいファイルだけでなく、上限付近のファイル、非対応形式、通信中断、処理失敗、再読み込み、別タブ復帰を確認します。

VideoCompress.ioの日本語版では、用途別の動画処理画面を提供しています。たとえば容量を扱うMP4動画圧縮と、形式を扱う動画変換では、利用者の目的は異なっても、アップロード・検証・処理・完了を分けて伝える考え方は共通です。

この記事はVideoCompress.ioの運営に関わる立場で、動画処理画面の設計・実装で再利用できる一般的な知見をまとめたものです。

まとめ

  • アップロード100%は処理全体の完了ではない
  • 送信、検証、ジョブ作成、処理、保存を別状態にする
  • 実ファイルと再生時間はサーバー側で検証する
  • ジョブIDと状態APIで非同期処理を追跡する
  • 再試行時の二重ジョブを防ぐ
  • エラー文は次の行動まで示す
  • 分析イベントも段階別に記録する

進捗率を増やすことより、現在どの段階で、利用者が次に何をすべきかを正確に伝えることが重要です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?