※このコンテンツは生成AIによって作成されています。
動画を見ているとき、「あ、この瞬間を画像として保存したい!」と思ったことはありませんか?
例えば、ソフトウェアの操作マニュアルを作成するために画面録画から特定の手順を切り出したい時や、好きな推しの動画の一瞬を保存したい時などです。
通常のスクリーンショット機能だと、再生バーが映り込んだり、画質が落ちたり、微妙にタイミングがずれたりしてイライラすることがよくあります。特に業務マニュアル用の画像では、正確なタイミングと鮮明さが求められます。
そこで今回は、動画をアップロードして、コマ送りで微調整しながら、元の画質のまま綺麗に画像を保存できるWebアプリケーションを開発しました。
サーバーに動画を送らず、すべてブラウザ上で完結するため、社外秘の動画でも安心して扱えるセキュアで爆速な仕様です。
この記事では、この「動画スクリーンショット・メーカー」の機能紹介と、開発における技術的なポイントを解説します。
🚀 アプリケーションの主な魅力
今回作成したアプリのこだわりポイントは以下の3点です。
1. 秒単位じゃない!「コマ送り」レベルの微調整
動画の一時停止ボタンだけでは、コンマ数秒のズレを調整するのは至難の業です。
このアプリでは、「1/30秒」「0.1秒」単位でのコマ送り・コマ戻し機能を搭載しました。
「まばたきの瞬間」はもちろん、「マニュアル作成時にメニューが開いた瞬間の画像が欲しい」といった細かいニーズにも応えられます。
2. 表示サイズに関係なく「元解像度」で保存
通常のスクショだと、画面に表示されているサイズ(例えばスマホなら小さく)でしか保存できません。
しかし、このアプリでは内部的に動画の元の解像度(4Kなら4K、フルHDならフルHD)を取得して画像を生成します。
プレビューが小さくても、保存される画像は最高画質なので、資料に貼り付けた際も文字が潰れません。
3. 面倒なインストール不要&プライバシー保護(業務利用も安心)
HTMLファイル1つ(+CDN)で動作するため、アプリのインストールは不要です。
また、動画処理はすべてユーザーのブラウザ内(ローカル)で行われます。重たい動画ファイルをサーバーにアップロードする必要がなく、業務マニュアル用の社外秘動画やプライベートな動画でも情報流出の心配なく安全に利用できます。
🛠️ 技術的なポイント (Coding Deep Dive)
このアプリは、外部ライブラリとして Tailwind CSS (デザイン)、Lucide (アイコン)、JSZip (一括DL) を使用していますが、コアロジックはVanilla JS (素のJavaScript) で記述しています。
開発における面白いポイントをいくつか紹介します。
💡 ポイント1: 動画の「生」のサイズでキャプチャする
HTML5の <video> タグと <canvas> タグを組み合わせるのが基本ですが、単に表示されている動画をキャプチャすると画質が荒くなってしまいます。
そこで、CanvasのサイズをCSS上の表示サイズではなく、動画ファイルが本来持っている videoWidth / videoHeight に設定するのがコツです。
// スクリーンショットボタンの処理
btnCapture.addEventListener('click', () => {
// Canvasのサイズを、動画の「元解像度」に合わせる
// これにより、画面上で動画が小さく表示されていても、高画質で保存できる
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx \= canvas.getContext('2d');
// 現在のフレームをCanvasに描画
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 画像データ(DataURL)として取得
const dataUrl \= canvas.toDataURL('image/png');
// ...保存処理へ
});💡 ポイント2: currentTime による精密なシーク制御
動画の時間を操作するには video.currentTime プロパティを使います。
UI上の「コマ送り」ボタンが押されたとき、単に時間を足すだけでなく、ユーザーが選択した「ステップ数(0.033秒など)」に応じて加算することで、フレーム単位のような操作感を実現しました。
// コマ送りボタンの実装例
btnForward.addEventListener('click', () => {
video.pause(); // 操作時は一時停止させるのが親切
// ユーザーがプルダウンで選んだ秒数(例: 1/30秒 \= 0.033)
const step \= parseFloat(stepSizeSelect.value);
// 動画の長さを超えないようにセット
video.currentTime \= Math.min(video.duration, video.currentTime \+ step);
});💡 ポイント3: クライアントサイドだけでZIP圧縮
複数枚のスクショを撮ったあと、1枚ずつ保存するのは面倒です。
そこで JSZip ライブラリを使用して、ブラウザ上で画像をZIPファイルにまとめて一括ダウンロードできるようにしました。
マニュアル作成時には大量の画面キャプチャが必要になることが多いので、この機能は特に便利です。
// 一括ダウンロード機能
btnDownloadAll.addEventListener('click', async () => {
const zip = new JSZip();
const folder = zip.folder("screenshots");
// ギャラリーの画像をループ処理
images.forEach((img, index) \=\> {
// DataURL ("data:image/png;base64,.....") から実データを抽出
const data \= img.src.split(',')\[1\];
folder.file(\`capture\_${index}.png\`, data, {base64: true});
});
// ZIPを生成してダウンロード発火
const content \= await zip.generateAsync({type:"blob"});
// ... \<a\>タグを生成してclick() ...
});📝 まとめ
今回は、HTML5 Video APIとCanvasを活用して、実用的な動画ツールを作ってみました。
特に動画処理系はサーバーサイドでやるとコスト(CPU/帯域)がかかりますが、最近のPCやスマホは高性能なので、今回のようにクライアントサイド(JavaScript)だけで処理を完結させる構成は非常に相性が良いです。
UIも Tailwind CSS を使うことで、ダークモード基調のモダンな見た目をサクッと作ることができました。
「ただ動画を再生する」だけでなく、「動画からデータを取り出す」という視点を持つと、Webブラウザでできることの幅がもっと広がりそうです!
完成版のコードはこちら
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>動画スクリーンショット・メーカー</title>
<!-- Tailwind CSS (スタイリング用) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- アイコン用 (Lucide) -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- JSZip (一括ダウンロード用) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style>
/* カスタムスクロールバー */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* レンジスライダーのカスタマイズ */
input[type=range] {
-webkit-appearance: none;
background: transparent;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
margin-top: -6px;
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: #334155;
border-radius: 2px;
}
</style>
</head>
<body class="bg-slate-900 text-slate-100 min-h-screen font-sans">
<div class="container mx-auto px-4 py-8 max-w-6xl">
<!-- ヘッダー -->
<header class="mb-8 text-center">
<h1 class="text-3xl font-bold mb-2 flex items-center justify-center gap-2">
<i data-lucide="camera" class="w-8 h-8 text-blue-500"></i>
動画スクリーンショット・メーカー
</h1>
<p class="text-slate-400">動画をアップロードして、好きな瞬間を高画質で保存できます</p>
</header>
<main class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- 左カラム:プレイヤーと操作 -->
<div class="lg:col-span-2 space-y-6">
<!-- アップロードエリア -->
<div id="upload-area" class="border-2 border-dashed border-slate-600 rounded-xl p-8 text-center hover:bg-slate-800 transition-colors cursor-pointer relative group">
<input type="file" id="file-input" accept="video/*" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
<div class="space-y-2 pointer-events-none">
<i data-lucide="upload-cloud" class="w-12 h-12 mx-auto text-blue-500 group-hover:scale-110 transition-transform"></i>
<p class="font-medium text-lg">動画ファイルをドロップ または クリックして選択</p>
<p class="text-sm text-slate-500">MP4, WebM, MOV などに対応</p>
</div>
</div>
<!-- 動画プレイヤーコンテナ (最初は非表示) -->
<div id="player-container" class="hidden bg-black rounded-xl overflow-hidden shadow-2xl relative group">
<video id="main-video" class="w-full max-h-[60vh] mx-auto" playsinline></video>
<!-- 読み込み中オーバーレイ -->
<div id="loading-overlay" class="absolute inset-0 bg-black/50 flex items-center justify-center hidden">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-white"></div>
</div>
</div>
<!-- コントロールパネル (動画ロード後に表示) -->
<div id="controls-panel" class="hidden bg-slate-800 p-4 rounded-xl shadow-lg space-y-4">
<!-- シークバー -->
<div class="flex items-center gap-4">
<span id="current-time" class="text-sm font-mono text-slate-400 w-16 text-right">0:00</span>
<input type="range" id="seek-slider" min="0" value="0" step="0.001" class="flex-grow h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer">
<span id="duration" class="text-sm font-mono text-slate-400 w-16">0:00</span>
</div>
<!-- ボタン群 -->
<div class="flex flex-wrap items-center justify-between gap-4">
<!-- 再生・コマ送り操作 -->
<div class="flex items-center gap-2">
<button id="btn-backward" class="p-2 hover:bg-slate-700 rounded-lg text-slate-300 hover:text-white" title="-0.05秒 (コマ戻し)">
<i data-lucide="skip-back" class="w-5 h-5"></i>
</button>
<button id="btn-play-pause" class="p-3 bg-blue-600 hover:bg-blue-700 rounded-full text-white shadow-lg transition-transform hover:scale-105">
<i data-lucide="play" class="w-6 h-6 fill-current"></i>
</button>
<button id="btn-forward" class="p-2 hover:bg-slate-700 rounded-lg text-slate-300 hover:text-white" title="+0.05秒 (コマ送り)">
<i data-lucide="skip-forward" class="w-5 h-5"></i>
</button>
</div>
<!-- 速度調整など -->
<div class="flex items-center gap-2 bg-slate-900 rounded-lg p-1">
<span class="text-xs text-slate-400 px-2">微調整幅:</span>
<select id="step-size" class="bg-slate-800 text-xs border-none rounded text-slate-200 py-1 focus:ring-0 cursor-pointer">
<option value="0.033">1/30秒</option>
<option value="0.1">0.1秒</option>
<option value="0.5">0.5秒</option>
<option value="1.0">1.0秒</option>
</select>
</div>
<!-- キャプチャボタン -->
<button id="btn-capture" class="flex items-center gap-2 px-6 py-2 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-lg shadow-lg transition-transform hover:scale-105 active:scale-95 ml-auto">
<i data-lucide="aperture" class="w-5 h-5"></i>
<span>保存する</span>
</button>
</div>
</div>
</div>
<!-- 右カラム:ギャラリー -->
<div class="bg-slate-800 rounded-xl p-4 h-fit flex flex-col max-h-[calc(100vh-100px)]">
<div class="flex justify-between items-center mb-4 border-b border-slate-700 pb-2">
<h2 class="text-xl font-semibold flex items-center gap-2">
<i data-lucide="image" class="w-5 h-5 text-emerald-400"></i>
ギャラリー
</h2>
<!-- カウントと一括ダウンロード -->
<div class="flex items-center gap-2">
<button id="btn-download-all" class="hidden text-xs bg-blue-600 hover:bg-blue-500 text-white px-2 py-1 rounded flex items-center gap-1 transition-colors" title="すべての画像をZIPでダウンロード">
<i data-lucide="download" class="w-3 h-3"></i>
一括保存
</button>
<span id="gallery-count" class="bg-slate-700 text-slate-300 text-xs px-2 py-1 rounded-full">0枚</span>
</div>
</div>
<div id="gallery-container" class="space-y-4 overflow-y-auto pr-2 flex-grow min-h-[200px]">
<!-- ここにキャプチャ画像が追加されます -->
<div id="gallery-empty" class="text-center text-slate-500 py-12 flex flex-col items-center">
<i data-lucide="images" class="w-12 h-12 mb-2 opacity-50"></i>
<p>まだ画像がありません</p>
<p class="text-xs mt-1">「保存する」ボタンで追加されます</p>
</div>
</div>
</div>
</main>
<!-- Canvas (非表示・画像処理用) -->
<canvas id="hidden-canvas" class="hidden"></canvas>
</div>
<!-- 通知トースト -->
<div id="toast-container" class="fixed bottom-4 right-4 flex flex-col gap-2 pointer-events-none z-50"></div>
<script>
// Lucideアイコンの初期化
lucide.createIcons();
// 要素の取得
const fileInput = document.getElementById('file-input');
const uploadArea = document.getElementById('upload-area');
const video = document.getElementById('main-video');
const playerContainer = document.getElementById('player-container');
const controlsPanel = document.getElementById('controls-panel');
const playPauseBtn = document.getElementById('btn-play-pause');
const seekSlider = document.getElementById('seek-slider');
const currentTimeEl = document.getElementById('current-time');
const durationEl = document.getElementById('duration');
const btnBackward = document.getElementById('btn-backward');
const btnForward = document.getElementById('btn-forward');
const btnCapture = document.getElementById('btn-capture');
const stepSizeSelect = document.getElementById('step-size');
const canvas = document.getElementById('hidden-canvas');
const galleryContainer = document.getElementById('gallery-container');
const galleryEmpty = document.getElementById('gallery-empty');
const galleryCount = document.getElementById('gallery-count');
const btnDownloadAll = document.getElementById('btn-download-all');
const loadingOverlay = document.getElementById('loading-overlay');
let isPlaying = false;
let captureCount = 0;
// 時間フォーマット関数 (MM:SS)
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
// --- ファイルアップロード処理 ---
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('video/')) {
showToast('エラー: 動画ファイルを選択してください', 'error');
return;
}
const url = URL.createObjectURL(file);
video.src = url;
// UIの状態更新
playerContainer.classList.remove('hidden');
controlsPanel.classList.remove('hidden');
uploadArea.classList.add('hidden'); // アップロードエリアを隠す(再アップロードはリロードで対応とするか、ボタンを追加可)
// 動画メタデータ読み込み完了時
video.addEventListener('loadedmetadata', () => {
seekSlider.max = video.duration;
durationEl.textContent = formatTime(video.duration);
showToast('動画を読み込みました', 'success');
});
});
// --- 動画プレイヤー制御 ---
// 再生・一時停止
function togglePlay() {
if (video.paused) {
video.play();
} else {
video.pause();
}
}
playPauseBtn.addEventListener('click', togglePlay);
video.addEventListener('play', () => {
isPlaying = true;
updatePlayIcon();
});
video.addEventListener('pause', () => {
isPlaying = false;
updatePlayIcon();
});
function updatePlayIcon() {
const icon = playPauseBtn.querySelector('i'); // svg
// Lucideのアイコン入れ替えはDOM操作が必要だが、簡単のためinnerHTMLで置換
playPauseBtn.innerHTML = isPlaying
? '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pause w-6 h-6 fill-current"><rect width="4" height="16" x="6" y="4"/><rect width="4" height="16" x="14" y="4"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-play w-6 h-6 fill-current"><polygon points="6 3 20 12 6 21 6 3"/></svg>';
}
// シークバーの更新
video.addEventListener('timeupdate', () => {
if (!isDraggingSlider) {
seekSlider.value = video.currentTime;
currentTimeEl.textContent = formatTime(video.currentTime);
}
});
// シークバー操作
let isDraggingSlider = false;
seekSlider.addEventListener('input', () => {
isDraggingSlider = true;
currentTimeEl.textContent = formatTime(seekSlider.value);
video.currentTime = seekSlider.value;
});
seekSlider.addEventListener('change', () => {
isDraggingSlider = false;
});
// コマ送り・コマ戻し
btnForward.addEventListener('click', () => {
video.pause();
const step = parseFloat(stepSizeSelect.value);
video.currentTime = Math.min(video.duration, video.currentTime + step);
});
btnBackward.addEventListener('click', () => {
video.pause();
const step = parseFloat(stepSizeSelect.value);
video.currentTime = Math.max(0, video.currentTime - step);
});
// --- スクリーンショット機能 ---
btnCapture.addEventListener('click', () => {
if (video.readyState < 2) {
showToast('動画が読み込まれていません', 'error');
return;
}
// Canvasのサイズを動画の元の解像度に合わせる
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 画像データ生成
try {
const dataUrl = canvas.toDataURL('image/png');
addScreenshotToGallery(dataUrl);
showToast('スクリーンショットを保存しました', 'success');
// キャプチャ時のフラッシュエフェクト
const flash = document.createElement('div');
flash.className = 'absolute inset-0 bg-white opacity-50 pointer-events-none transition-opacity duration-200';
playerContainer.appendChild(flash);
setTimeout(() => flash.style.opacity = '0', 50);
setTimeout(() => flash.remove(), 250);
} catch (e) {
console.error(e);
showToast('画像の生成に失敗しました', 'error');
}
});
function addScreenshotToGallery(dataUrl) {
captureCount++;
galleryEmpty.classList.add('hidden');
galleryCount.textContent = `${captureCount}枚`;
// 一括ダウンロードボタンを表示
if (captureCount > 0) {
btnDownloadAll.classList.remove('hidden');
}
const timestamp = formatTime(video.currentTime).replace(':', '-');
const filename = `capture_${timestamp}_${captureCount}.png`;
// カード要素作成
const card = document.createElement('div');
card.className = 'bg-slate-700 rounded-lg p-2 animate-fadeIn flex flex-col gap-2 group relative';
// サムネイル画像
const imgContainer = document.createElement('div');
imgContainer.className = 'relative overflow-hidden rounded bg-black aspect-video cursor-pointer';
const img = document.createElement('img');
img.src = dataUrl;
img.dataset.filename = filename; // 一括ダウンロード用にファイル名を保持
img.className = 'w-full h-full object-contain transition-transform duration-300 group-hover:scale-105';
// 画像クリックで拡大(簡易的な別タブ表示)
imgContainer.onclick = () => {
const w = window.open('about:blank');
const image = new Image();
image.src = dataUrl;
w.document.write(image.outerHTML);
};
imgContainer.appendChild(img);
// 操作エリア
const actions = document.createElement('div');
actions.className = 'flex justify-between items-center px-1';
const info = document.createElement('span');
info.className = 'text-xs text-slate-400 font-mono';
info.textContent = `${formatTime(video.currentTime)} | ${video.videoWidth}x${video.videoHeight}`;
const buttons = document.createElement('div');
buttons.className = 'flex gap-2';
// ダウンロードボタン
const dlBtn = document.createElement('a');
dlBtn.href = dataUrl;
dlBtn.download = filename;
dlBtn.className = 'p-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded shadow transition-colors';
dlBtn.title = 'ダウンロード';
dlBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>';
// 削除ボタン
const delBtn = document.createElement('button');
delBtn.className = 'p-1.5 bg-red-600 hover:bg-red-500 text-white rounded shadow transition-colors';
delBtn.title = '削除';
delBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>';
delBtn.onclick = () => {
card.remove();
captureCount--;
galleryCount.textContent = `${captureCount}枚`;
if (captureCount === 0) {
galleryEmpty.classList.remove('hidden');
btnDownloadAll.classList.add('hidden'); // 画像がなくなったらボタンも隠す
}
};
buttons.appendChild(dlBtn);
buttons.appendChild(delBtn);
actions.appendChild(info);
actions.appendChild(buttons);
card.appendChild(imgContainer);
card.appendChild(actions);
// ギャラリーの先頭に追加
galleryContainer.insertBefore(card, galleryContainer.firstChild);
}
// --- 一括ダウンロード処理 ---
btnDownloadAll.addEventListener('click', async () => {
const images = galleryContainer.querySelectorAll('img');
if (images.length === 0) return;
showToast('Zipファイルを作成中...', 'default');
const zip = new JSZip();
const folder = zip.folder("screenshots");
// 画像をZipに追加
images.forEach((img, index) => {
const src = img.src;
// data:image/png;base64,... のカンマ以降を取得
const data = src.split(',')[1];
// datasetに保存したファイル名を使用、なければ連番
const filename = img.dataset.filename || `screenshot_${index + 1}.png`;
folder.file(filename, data, {base64: true});
});
try {
// Zip生成
const content = await zip.generateAsync({type:"blob"});
// ダウンロードトリガー
const a = document.createElement("a");
const url = URL.createObjectURL(content);
a.href = url;
a.download = "screenshots.zip";
document.body.appendChild(a);
a.click();
// クリーンアップ
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
showToast('一括ダウンロードを開始しました', 'success');
} catch (err) {
console.error(err);
showToast('Zip作成に失敗しました', 'error');
}
});
// --- ユーティリティ: トースト通知 ---
function showToast(message, type = 'default') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
// 色の設定
const bgClass = type === 'error' ? 'bg-red-600' : (type === 'success' ? 'bg-emerald-600' : 'bg-slate-700');
toast.className = `${bgClass} text-white px-4 py-3 rounded shadow-lg flex items-center gap-2 transform transition-all duration-300 translate-y-full opacity-0 pointer-events-auto min-w-[200px] z-50`;
toast.innerHTML = `<span>${message}</span>`;
container.appendChild(toast);
// アニメーション
requestAnimationFrame(() => {
toast.classList.remove('translate-y-full', 'opacity-0');
});
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Tailwindにアニメーション追加用style
const style = document.createElement('style');
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}
`;
document.head.appendChild(style);
</script>
</body>
</html>