動画を見ながら学習したり、ゲームの録画を見返したりしていると、「この場面で何を思ったか」を残したくなることがあります。
普通のメモアプリに書いてもいいのですが、あとで見返すと「このメモ、動画のどの場面の話だっけ?」となりがちです。
そこで、ローカルに置いた動画を再生しながら、現在の再生位置にメモを保存できるWebアプリを作りました。
メモは一覧で見られるだけでなく、動画上にニコニコ動画風の流れるコメントとして重ねて表示できるようにしています。
作ったもの
構成は、RustのバックエンドとReactのフロントエンドに分けています。
video_memo/
├── compose.yaml
├── backend/
│ ├── src/
│ │ ├── main.rs
│ │ ├── api.rs
│ │ ├── db.rs
│ │ ├── handlers.rs
│ │ └── model.rs
│ └── migrations/
├── frontend/
│ └── src/
│ ├── App.tsx
│ └── components/
├── videos/
└── data/
videos/ に動画ファイルを置くと、バックエンド起動時に自動でスキャンしてSQLiteへ登録します。
アプリ側では、左のサイドバーから動画を選び、右側のメモ欄にコメントを書きます。送信すると、その時点の再生秒数と一緒にメモが保存されます。
技術スタック
バックエンドはRust + Axumです。
- AxumでAPIを作る
- SQLxでSQLiteへ保存する
- tower-httpの
ServeDirでローカル動画を配信する - 起動時に
videos/をスキャンする
フロントエンドはReact + Vite + TypeScriptです。
- ネイティブの
<video>タグで動画を再生する onTimeUpdateで現在の再生位置をReact stateへ同期する- MUIでサイドバー、メモ一覧、入力UIを組む
- CSS animationでコメントを右から左へ流す
開発環境はPodman Composeでまとめています。
services:
backend:
build: ./backend
ports:
- "3000:3000"
volumes:
- ./backend:/app
- ./videos:/app/videos
- ./data:/app/data
environment:
- DATABASE_URL=sqlite:/app/data/app.db
frontend:
build: ./frontend
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
動画ファイルとDBファイルをホスト側に残したいので、videos/ と data/ はbind mountしています。
データモデルはシンプルにする
必要なテーブルは2つだけです。
CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
source_type TEXT NOT NULL,
path_or_url TEXT NOT NULL,
duration INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS memos (
id TEXT PRIMARY KEY,
video_id TEXT NOT NULL,
timestamp REAL NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
);
ポイントは、メモの timestamp を REAL にしているところです。
動画の再生位置は小数秒で取れるので、整数秒に丸めずそのまま保存します。これで、コメント表示やメモのハイライトを少し自然にできます。
起動時に動画フォルダをスキャンする
このアプリは個人用のローカルツールなので、動画アップロード機能を最初から作り込むより、videos/ フォルダに置いたファイルを自動で拾うほうが楽です。
バックエンド起動時に、SQLiteのマイグレーションを実行したあとで scan_local_videos を呼び出しています。
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
db::scan_local_videos(&pool)
.await
.expect("Failed to scan local videos");
スキャン処理では、既にDBに登録済みのローカル動画を取得しておき、まだ存在しないファイルだけを追加します。
let existing_local_videos: Vec<Video> = sqlx::query_as::<_, Video>(
"SELECT * FROM videos WHERE source_type = 'local'"
)
.fetch_all(pool)
.await?;
let mut existing_paths: HashSet<String> = existing_local_videos
.into_iter()
.map(|v| v.path_or_url)
.collect();
DB登録済みかどうかをファイル名で見ているので、同じファイルを何度も登録しないようにできます。
動画配信はServeDirに任せる
ローカル動画は、Axum側で /stream に静的配信として載せています。
let app = Router::new()
.nest("/api", api::create_router())
.nest_service("/stream", ServeDir::new("videos"))
.route("/", get(handlers::health_check))
.layer(cors)
.with_state(state);
これで、フロントエンドからは次のようなURLで動画を参照できます。
const getVideoUrl = (video: Video) => {
if (video.source_type === 'local') {
return `${API_BASE_URL}/stream/${encodeURIComponent(video.path_or_url)}`;
}
return video.path_or_url;
};
日本語ファイル名やスペースを含むファイル名でも壊れにくいように、ファイル名は encodeURIComponent しています。
メモは現在の再生位置に紐づける
フロントエンド側では、動画の現在位置を playbackTime として持っています。
VideoPlayer コンポーネントでは、ネイティブの <video> タグの onTimeUpdate から currentTime を拾います。
onTimeUpdate={(e) => {
const target = e.target as HTMLVideoElement;
onProgress({
played: target.currentTime / target.duration,
playedSeconds: target.currentTime,
loaded: target.buffered.length > 0
? target.buffered.end(target.buffered.length - 1) / target.duration
: 0,
loadedSeconds: target.buffered.length > 0
? target.buffered.end(target.buffered.length - 1)
: 0
});
}}
メモを投稿するときは、この playbackTime をそのままAPIへ送ります。
const newMemo: CreateMemo = {
video_id: currentVideo.id,
timestamp: playbackTime,
content: content,
};
バックエンドでは、受け取った video_id、timestamp、content をSQLiteに保存します。
sqlx::query_as::<_, Memo>(
"INSERT INTO memos (id, video_id, timestamp, content, created_at) VALUES (?, ?, ?, ?, ?) RETURNING *"
)
.bind(&id)
.bind(&new_memo.video_id)
.bind(&new_memo.timestamp)
.bind(&new_memo.content)
.bind(&created_at)
.fetch_one(pool)
.await
この作りにしておくと、メモ一覧、シーク、コメントオーバーレイの全部が同じ timestamp を基準に動かせます。
メモ一覧からその場面にジャンプする
メモ一覧では、各メモの時刻を 00:01:23 のような形式で表示しています。
const formatTime = (seconds: number) => {
const date = new Date(0);
date.setSeconds(seconds);
return date.toISOString().substr(11, 8);
};
メモをクリックすると、その時刻にシークします。
const handleSeekToMemo = (timestamp: number) => {
if (playerRef.current) {
playerRef.current.seekTo(timestamp, 'seconds')
setPlaybackTime(timestamp)
}
}
ここは今後直したいところでもあります。
現在のプレイヤーはネイティブ <video> なので、seekTo ではなく currentTime を直接変更する形に寄せたほうが自然です。最初は react-player 想定の名残が残っているので、実装を合わせる余地があります。
現在位置に近いメモをハイライトする
メモ一覧では、現在の再生位置に近いメモだけ背景を変えています。
const isActive = Math.abs(memo.timestamp - currentTime) < 2;
これだけでも、動画を見ながら「今どのメモのあたりを再生しているか」が分かりやすくなります。
細かい機能ですが、動画とテキストが同期している感覚が出るので、使い心地にはかなり効きます。
流れるコメントをCSS animationで作る
このアプリで一番見た目に楽しいのが、流れるコメントモードです。
コメントは動画の上に絶対配置し、右から左へCSS animationで流しています。
@keyframes comment-slide {
0% {
left: 100%;
transform: translateX(0);
}
100% {
left: 0%;
transform: translateX(-100%);
}
}
.comment-move {
position: absolute;
left: 100%;
white-space: nowrap;
color: white;
text-shadow: 2px 2px 4px #000000;
font-weight: bold;
animation-name: comment-slide;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
表示対象のメモは、現在時刻から見て表示時間内にあるものだけに絞ります。
const COMMENT_DURATION = 5
const BUFFER = 0.5
const activeMemos = memos.filter(
(memo) =>
currentTime >= memo.timestamp - BUFFER &&
currentTime < memo.timestamp + COMMENT_DURATION + BUFFER
)
BUFFER を少し持たせているのは、レンダリング境界でコメントがちらつくのを防ぐためです。
シーク時にコメント位置を合わせる
流れるコメントで難しいのは、動画を途中にシークしたときです。
ただ表示するだけなら簡単ですが、たとえばコメントの表示開始から2秒経過した位置へシークした場合、そのコメントも2秒進んだ状態から流れてほしいです。
そこで、コメントごとに animationDelay を負の値で設定しています。
const delayRef = useRef<number>(-(currentTime - memo.timestamp));
CSS animationでは、animation-delay: -2s のように負の値を指定すると、アニメーションが2秒進んだ状態から始まります。
ただし、currentTime は再生中ずっと変わるので、毎回 animationDelay を更新するとコメントの位置がガタつきます。
そのため、通常再生中はdelayを固定し、シークが起きたときだけ更新するようにしています。
if (seekCount !== lastSeekCountRef.current) {
delayRef.current = -(currentTime - memo.timestamp);
lastSeekCountRef.current = seekCount;
}
このあたりは、小さいけれど動画同期UIらしい実装ポイントでした。
動画削除はファイルもDBも消す
サイドバーから動画を削除すると、DBのレコードだけでなく、ローカルファイルも削除します。
if video.source_type == "local" {
let file_path = PathBuf::from("./videos").join(&video.path_or_url);
if file_path.exists() {
if let Err(e) = fs::remove_file(file_path) {
eprintln!("Failed to delete file: {}", e);
}
}
}
その後、関連メモと動画レコードを削除します。
ローカルツールとしては便利ですが、物理ファイルまで消える操作なので、フロント側では確認ダイアログを出しています。
作ってみてよかったところ
このアプリの良いところは、仕組みがかなり素直なことです。
動画ファイルをスキャンしてDBに登録する。動画を再生する。再生時間とメモを保存する。保存した時刻に合わせて表示する。
1つずつは単純ですが、つなげると「動画を見ながら考えたことを、あとから同じタイミングで見返せる」道具になります。
特に、次のような用途と相性が良さそうです。
- 学習動画の重要ポイントを残す
- ゲーム録画の反省点をメモする
- UI操作や検証動画に注釈を付ける
- 編集前の素材チェックにコメントを残す
今後改善したいこと
まだ荒いところもあります。
- ネイティブ
<video>に合わせてシーク処理を整理する - 動画追加・アップロード画面を作る
- YouTube URL対応を実装まで寄せる
- メモ検索やタグ付けを入れる
- コメントの重なりを避けるレーン管理を入れる
- 動画ごとのサムネイル生成をする
とはいえ、ローカル動画を見返しながらメモを残す用途としては、最小構成でも十分便利でした。
動画とメモは相性が良いので、次は「メモをどう整理するか」まで作ると、もっと実用的になりそうです。