動画アップロード機能では、進捗表示が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で非同期処理を追跡する
- 再試行時の二重ジョブを防ぐ
- エラー文は次の行動まで示す
- 分析イベントも段階別に記録する
進捗率を増やすことより、現在どの段階で、利用者が次に何をすべきかを正確に伝えることが重要です。