
Utage Manual
- 4 installs
- Updated July 30, 2026
- naoterumaker/manabi-skills
Helps with ai & agent building tasks.
About
utage-manual is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- utage-manual
- AI & Agent Building
- AI-coding skill
Utage Manual by the numbers
- 4 all-time installs (skills.sh)
- Ranked #13,372 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/naoterumaker/manabi-skills --skill utage-manualAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| Last updated | July 30, 2026 |
| Repository | naoterumaker/manabi-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
UTAGE動画マニュアル作成スキル
概要
UTAGEの動画講座ページから、読みやすいマニュアルを自動作成します。
処理フロー
1. 出力フォルダの確認(ユーザーに毎回確認) 2. 講座構成の把握と計画立案(Chrome連携) 3. 各章の動画URLを取得 4. ffmpegでHLS動画をダウンロード 5. 音声を抽出 6. Groq Whisperで文字起こし 7. シーン検出でスクリーンショットを抽出 8. 読みやすい文章スタイルでマニュアル作成 9. 画像と文章の整合性チェック 10. 全章結合版の作成(オプション)
---
Step 1: 講座ページと出力先の確認
MANDATORY: 処理を開始する前に、必ずユーザーに確認する。
📚 講座ページのURLを教えてください
(例: https://utage-system.com/members/xxxxx/course/xxxxx)
📁 出力先フォルダ: [現在のディレクトリ]/[講座名]_manual/
(変更したい場合は教えてください)注意: ユーザーから講座URLを受け取ってから、Chrome連携で講座ページにアクセスする。
---
出力ファイル構成(例)
⚠️ 以下はフォルダ構成の例です。実際のフォルダ名・ファイル名は講座内容に応じて変わります。
[出力フォルダ]/
├── videos/
│ ├── [章番号]_[タイトル].mp4
│ └── ...
├── audio/
│ ├── [章番号].mp3
│ └── ...
├── transcripts/
│ ├── [章番号].txt
│ └── ...
├── screenshots/
│ ├── [章番号]/
│ │ ├── title.jpg
│ │ ├── frame_001.jpg
│ │ └── ...
│ └── ...
├── manuals/
│ ├── [章番号]_[タイトル].md
│ └── ...
└── full_manual.md # 全章結合版---
Step 2: 講座構成の把握と計画立案
2-1. 講座ページにアクセス
// tabs_context_mcp でタブ情報を取得
// navigate で講座ページに移動
// read_page でページ構造を取得2-2. 章一覧を抽出
// javascript_tool で章リストを取得
const lessons = document.querySelectorAll('a[href*="/lesson/"]');
const chapters = Array.from(lessons).map((a, i) => ({
index: i,
title: a.textContent.trim(),
url: a.href
}));
JSON.stringify(chapters);2-3. 処理計画を作成
抽出した章一覧をユーザーに提示し、確認を取る:
📋 講座構成(全N章)
[抽出した章一覧を表示]
処理対象を選択してください:
- 全章処理
- 特定の章のみ(番号指定)
- 範囲指定(例: 0-5)---
Step 3: 動画URL取得
各章のレッスンページから動画URLを取得:
// iframeから動画URLを取得
const iframe = document.querySelector('iframe');
iframe.src // → https://utage-system.com/video/xxxxx動画ページに移動後、read_network_requests で .m3u8 URLを取得:
urlPattern: ".m3u8"
→ https://s3.ap-northeast-1.wasabisys.com/utagesystem-video/.../video.m3u8---
Step 4: 動画ダウンロード
スクリプト: `scripts/hls_downloader.py`
# 動画ダウンロード(UTAGE/Wasabi S3向け高速設定)
python scripts/hls_downloader.py "[m3u8 URL]" "videos/[章番号]_[タイトル].mp4"特徴:
- HTTP persistent接続で高速ダウンロード
- コピーモード(再エンコードなし)
- 自動リトライ対応
---
Step 5: 音声抽出
スクリプト: `scripts/hls_downloader.py`
# 動画から音声を抽出(MP3形式)
python scripts/hls_downloader.py --extract-audio "videos/[章番号]_[タイトル].mp4" "audio/[章番号].mp3"---
Step 6: 文字起こし
スクリプト: `scripts/transcribe.py`
# Groq Whisperで文字起こし
python scripts/transcribe.py "audio/[章番号].mp3" "transcripts/[章番号].txt"特徴:
- 3分チャンクで分割(安定性重視)
- 並列処理(チャンク数に応じて3〜10並列)
- レート制限時は自動リトライ
- GROQ_API_KEYは
.envファイルから自動読み込み
---
Step 7: スクリーンショット抽出
スクリプト: `scripts/screenshot_extractor.py`
# pHashモード(デフォルト・推奨): スライド変化を検出して重複を排除
python scripts/screenshot_extractor.py "videos/[章番号]_[タイトル].mp4" "screenshots/[章番号]"
# pHash閾値を調整(デフォルト=8, 小さいほど多く取る)
python scripts/screenshot_extractor.py "videos/[章番号]_[タイトル].mp4" "screenshots/[章番号]" --phash-threshold 6
# レガシーモード: 30秒間隔(pHashが使えない環境用)
python scripts/screenshot_extractor.py "videos/[章番号]_[タイトル].mp4" "screenshots/[章番号]" --no-phash --interval 30特徴:
- pHashモード(デフォルト)でスライド切替・UI変化を正確に検出
- 重複フレームを自動で排除(30秒間隔方式の弱点を解消)
- コンテンツ変化量に応じて枚数が自動調整される(スライド中心=少なめ、デモ中心=多め)
- imagehash + Pillow が必要(
pip install imagehash Pillow)
---
Step 8: マニュアル作成
MANDATORY: `writing-style.md` を読むこと。
文章スタイルのルール
- 箇条書き・表の多用は避ける
- 文章で流れるように説明
- 動画の語り口調を活かす
- 読者への問いかけを使う
- 最後に明確なアクションを提示
---
Step 9: マニュアルのレビューと修正
BLOCKER: マニュアル生成後のレビューは必須。生成完了 ≠ 完了。
各Agentに委譲する場合も、Agentプロンプトに必ず以下を含めること:
- "writing-style.md と image-alignment.md を必ず読むこと"
- "Step 9のレビューチェックリストを全項目通過するまで完了報告しないこと"
- "画像と文章の整合性を画像内容を確認しながらチェックすること"
MANDATORY: マニュアル作成後、必ず以下のレビューを実施し、問題があれば修正すること。
9-1. 画像チェック
必須確認項目:
- [ ] 各セクションに最低1枚は画像が貼られているか
- [ ] 画像パスが正しいか(
../screenshots/XX/frame_XXX.jpg形式) - [ ] 画像の内容に文章で言及しているか(「上の画像にあるように〜」など)
# 画像の参照数を確認
grep -c "!\[" manuals/[章番号]_*.md
# 各セクション(##)に画像があるか確認
grep -E "^##|!\[" manuals/[章番号]_*.md9-1-1. 画像が不足している場合の対処
画像が不足している場合は、以下の手順で追加する:
手順1: 文字起こしから画像が必要な箇所を特定
文字起こしを読み、以下のような箇所を特定:
- 操作手順の説明(「ここをタップして」「この画面で」など)
- 設定画面の説明
- 図解やスライドへの言及
- ツールのインターフェース説明
手順2: 必要なタイムスタンプを推定
文字起こしの位置から、動画内のおおよそのタイムスタンプを推定:
- 文字起こしの全体の長さと、該当箇所の位置から割合を計算
- 動画の長さに割合を掛けて、おおよその秒数を算出
例: 文字起こし全体が3000文字、該当箇所が1500文字目付近、動画が10分(600秒)の場合 → 1500/3000 × 600 = 300秒(5分)付近
手順3: 追加のスクリーンショットを抽出
# 特定のタイムスタンプでフレームを抽出
python scripts/screenshot_extractor.py "videos/[章番号]_[タイトル].mp4" "screenshots/[章番号]" --timestamps "120,180,240,300"
# または、より細かい間隔で再抽出(例: 15秒間隔)
python scripts/screenshot_extractor.py "videos/[章番号]_[タイトル].mp4" "screenshots/[章番号]" --interval 15手順4: 抽出した画像を確認してマニュアルに追加
1. Readツールで抽出した画像を確認 2. 適切な画像をマニュアルの該当箇所に追加 3. 画像の内容に言及する文章も追加

上の画像にあるように、この設定画面では〜9-1-2. 画像が必要な典型的なパターン
以下のパターンでは画像が必須:
1. 操作手順: 「タップ」「クリック」「選択」などの動作がある場合 2. 設定変更: 「オンにする」「チェックを入れる」などの場合 3. 画面説明: 「この画面」「ここに表示される」などの場合 4. 比較説明: 「Before/After」「変更前/変更後」の場合 5. 複雑な概念: 図解があると理解しやすい場合
9-2. 読みやすさチェック
確認項目:
- [ ] 箇条書きが3つ以上連続していないか
- [ ] 表が2つ以上連続していないか
- [ ] セクション間の繋がりは自然か(「では」「さて」「ここで」などの接続)
- [ ] 動画の語り口調が活きているか
- [ ] 最後に明確なアクション(次にやること)があるか
9-3. 整合性チェック
MANDATORY: `image-alignment.md` を読むこと。
1. 各画像の内容を確認: Readツールで画像ファイルを読み込む 2. 画像内のテキスト・図を把握: タイトル、見出し、図解の内容をメモ 3. 整合性を確認: 画像を参照する箇所の文章が、画像の内容と一致しているか 4. 不一致があれば修正: 画像の内容に合わせて文章を調整
9-4. 修正の実施
レビューで問題が見つかった場合は、必ず修正してから次の章に進む。
✅ レビュー完了チェックリスト
- 画像: 各セクションに配置済み
- 読みやすさ: 箇条書き過多なし、セクション接続OK
- 整合性: 画像と文章の内容が一致---
Agent委譲時の必須指示テンプレート
マニュアル生成を複数Agentに分散する場合、以下のテンプレートに従う:
```` Agent( model: "sonnet", prompt: """ 必ず以下を読んでから生成開始:
- ~/.claude/skills/utage-manual/writing-style.md
- ~/.claude/skills/utage-manual/image-alignment.md
生成対象: ch{N}-{M}
各章について: 1. transcript.txt 全文読込 (BLOCKER) 2. screenshots/ から最低5枚をRead (画像内容把握 - BLOCKER) 3. マニュアル生成 (writing-style.md準拠) 4. レビュー実施:
- 各セクションに画像があるか
- 画像と文章の内容が整合しているか
- 箇条書き3つ以上連続していないか
- 流れる文章になっているか
5. レビューで問題があれば修正してから完了報告
完了報告時にレビュー結果も含めること。 """ ) ````
NG/OK対照表
| NG | OK | 理由 |
|---|---|---|
| Agentにマニュアル生成依頼するときレビュー指示なし | レビュー指示必須 | レビューなしだと整合性破綻 |
| 画像を読まずにファイル名から推測 | 必ずReadで画像内容確認 | ファイル名は内容を表さない |
| writing-style.md読まずに生成 | 必ず読んでから | スタイル一貫性のため |
---
Step 10: 全章結合版の作成(オプション)
全章のマニュアルを1つのファイルに結合:
# [講座名] 完全マニュアル
## 目次
[各章へのリンクを生成]
---
# 第X章:[タイトル]
[各章のマニュアル内容を結合]
...---
必要環境
- GROQ_API_KEY(
.envファイルに保存済み) - ffmpeg(動画処理)
- Python 3 + requests(文字起こしスクリプト用)
- Chrome + Claude in Chrome拡張機能
スクリプト一覧
| スクリプト | 用途 |
|---|---|
| `scripts/hls_downloader.py` | HLS動画ダウンロード+音声抽出 |
| `scripts/transcribe.py` | Groq Whisper文字起こし(並列処理) |
| `scripts/screenshot_extractor.py` | スクリーンショット抽出(間隔/シーン検出) |
インストール
# 必要なPythonパッケージ
pip install requests
# ffmpegのインストール(macOS)
brew install ffmpegGROQ_API_KEY=your_groq_api_key_here
画像と文章の整合性チェックガイド
なぜ整合性チェックが必要か
動画から自動抽出したスクリーンショットは、必ずしもマニュアルの文章と一致するタイミングで撮られているとは限りません。
よくある問題:
- 画像では「即実践コース」を説明しているのに、文章では「着実コース」の話をしている
- 画像に表示されている図解と、文章で説明している順序が異なる
- 画像のタイトルと、マニュアルのセクション見出しが一致していない
---
チェック手順
Step 1: 各画像の内容を確認
Readツールで画像ファイルを読み込み、内容を把握する:
Read: screenshots/00/frame_001.jpg
Read: screenshots/00/frame_002.jpg
...Step 2: 画像内容をメモ
各画像について以下を記録:
| 画像 | タイトル/見出し | 図解の内容 | キーワード |
|---|---|---|---|
| title.jpg | 重要:AI講座の活用方法 | - | タイトル |
| frame_001.jpg | 目次 | 0-12章のリスト | 章一覧 |
| frame_002.jpg | 2つのコース | 即実践/着実 | コース選択 |
| frame_003.jpg | 即実践コース | 9-11章の詳細 | 魔法プロンプト |
| ... | ... | ... | ... |
Step 3: マニュアルとの照合
マニュアル内で各画像を参照している箇所を確認:

即実践コースでは、以下の3つの章を学びます...チェックポイント:
- 画像のalt属性(
即実践コースの内容)が画像内容と一致しているか - 画像の直後の文章が、画像で説明されている内容と一致しているか
- 画像内の図解の順序と、文章での説明順序が合っているか
Step 4: 不一致の修正
パターン1: 画像を差し替える
文章に合う別の画像がある場合:
# Before
 # 実際は即実践コースの画像
# After
 # 着実コースの画像に差し替えパターン2: 文章を修正する
画像に合わせて文章を調整:
# Before

着実コースでは、基礎から学びます... # 画像と不一致
# After

即実践コースでは、第9章から始めます... # 画像と一致パターン3: 画像の配置を変更する
画像の位置を移動:
# Before(画像の位置が不適切)
## 即実践コースについて
説明文...
 # 場違い
# After(適切な位置に移動)
## 即実践コースについて
説明文...
## 着実コースについて
 # 適切な位置---
チェックリスト
マニュアル完成後、以下を確認:
- [ ] 各画像のalt属性が画像内容を正確に表しているか
- [ ] 画像の直後の文章が画像内容と一致しているか
- [ ] 画像内の図解と文章の説明順序が合っているか
- [ ] 画像で説明されている項目が文章にも含まれているか
- [ ] 不要な画像(内容が重複、または関連性が低い)がないか
---
画像が不足している場合の追加手順
Step 1: 不足箇所の特定
マニュアルを読み、以下の箇所をチェック:
- 操作手順があるのに画像がない
- 「この画面」「ここで」などの指示語があるのに画像がない
- 設定変更の説明があるのに画像がない
Step 2: 該当するタイムスタンプを推定
文字起こしの位置から、動画内の秒数を推定:
# 推定式
推定秒数 = (該当箇所の文字位置 / 文字起こし全体の文字数) × 動画の長さ(秒)Step 3: 追加のスクリーンショットを抽出
# 特定のタイムスタンプで抽出(例: 2分、3分、4分、5分の位置)
python scripts/screenshot_extractor.py "videos/XX_タイトル.mp4" "screenshots/XX" --timestamps "120,180,240,300"Step 4: 抽出した画像を確認
# Readツールで画像を確認
Read: screenshots/XX/ts_000_00120.jpg
Read: screenshots/XX/ts_001_00180.jpg
...Step 5: マニュアルに画像を追加
適切な画像をマニュアルに追加し、画像の内容に言及する文章も追加:

上の画像のように、この設定画面では「〜」をオンにします。---
自動化のヒント
画像を読み込む際に、以下の情報を自動的に抽出してメモを作成すると効率的:
1. 画像内のタイトルテキスト(OCR的に認識) 2. 図解の種類(フローチャート、表、リストなど) 3. 主要なキーワード 4. 動画内でのタイムスタンプ(ファイル名から推測)
これらをまとめた「画像インデックス」を作成しておくと、整合性チェックが楽になる。
#!/usr/bin/env python3
"""
UTAGE HLS動画ダウンローダー
UTAGE/Wasabi S3のHLS動画をダウンロードする専用スクリプト。
高速なHTTP persistent接続を使用。
Usage:
python hls_downloader.py <m3u8_url> <output_path>
Example:
python hls_downloader.py "https://s3.ap-northeast-1.wasabisys.com/.../video.m3u8" "videos/01_intro.mp4"
"""
import subprocess
import sys
import shutil
from pathlib import Path
from typing import Optional
def download_hls_video(
m3u8_url: str,
output_path: str,
timeout: int = 600
) -> bool:
"""
UTAGE/Wasabi S3向けのHLS動画ダウンロード
Args:
m3u8_url: HLSプレイリストのURL(.m3u8)
output_path: 出力先MP4ファイルパス
timeout: タイムアウト秒数(デフォルト10分)
Returns:
bool: 成功した場合True
"""
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
ffmpeg_path = shutil.which("ffmpeg")
if not ffmpeg_path:
print("Error: ffmpeg not found. Please install ffmpeg first.")
print(" macOS: brew install ffmpeg")
print(" Linux: apt install ffmpeg")
return False
# UTAGE/Wasabi S3向け高速設定
cmd = [
ffmpeg_path,
"-y",
# HTTP persistent接続(高速化のキー)
"-http_persistent", "1",
"-multiple_requests", "1",
"-reconnect", "1",
"-protocol_whitelist", "file,http,https,tcp,tls,crypto",
"-i", m3u8_url,
# コピーモード(再エンコードなし)
"-c", "copy",
"-bsf:a", "aac_adtstoasc",
str(output),
]
try:
print(f"Downloading: {m3u8_url[:80]}...")
print(f"Output: {output}")
result = subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout
)
if output.exists():
size_mb = output.stat().st_size / (1024 * 1024)
print(f"Download complete: {size_mb:.1f} MB")
return True
else:
print("Error: Output file not created")
return False
except subprocess.TimeoutExpired:
print(f"Error: Download timed out after {timeout} seconds")
return False
except subprocess.CalledProcessError as e:
err = e.stderr if e.stderr else ""
if "HTTP error 403" in err:
print("Error: Access denied (403)")
print("The URL token may have expired. Please get a new URL.")
elif "HTTP error 404" in err:
print("Error: Video not found (404)")
print("The video may have been removed or the URL is invalid.")
else:
print(f"Error: ffmpeg failed")
print(err[:300] if err else "Unknown error")
return False
def extract_audio(
video_path: str,
audio_path: str,
sample_rate: int = 16000
) -> bool:
"""
動画から音声を抽出(MP3形式)
Args:
video_path: 入力動画ファイルパス
audio_path: 出力音声ファイルパス
sample_rate: サンプリングレート
Returns:
bool: 成功した場合True
"""
video = Path(video_path)
audio = Path(audio_path)
if not video.exists():
print(f"Error: Video file not found: {video}")
return False
audio.parent.mkdir(parents=True, exist_ok=True)
ffmpeg_path = shutil.which("ffmpeg")
if not ffmpeg_path:
print("Error: ffmpeg not found")
return False
cmd = [
ffmpeg_path,
"-y",
"-i", str(video),
"-vn", # 映像なし
"-acodec", "libmp3lame",
"-ar", str(sample_rate),
"-ac", "1", # モノラル
"-q:a", "2", # 高品質
str(audio),
]
try:
print(f"Extracting audio: {video.name} -> {audio.name}")
subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=300
)
if audio.exists():
size_mb = audio.stat().st_size / (1024 * 1024)
print(f"Audio extracted: {size_mb:.1f} MB")
return True
else:
print("Error: Audio file not created")
return False
except subprocess.CalledProcessError as e:
print(f"Error: Audio extraction failed")
print(e.stderr[:200] if e.stderr else "Unknown error")
return False
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python hls_downloader.py <m3u8_url> <output_path>")
print(" python hls_downloader.py --extract-audio <video_path> <audio_path>")
sys.exit(1)
if sys.argv[1] == "--extract-audio":
if len(sys.argv) < 4:
print("Usage: python hls_downloader.py --extract-audio <video_path> <audio_path>")
sys.exit(1)
success = extract_audio(sys.argv[2], sys.argv[3])
else:
success = download_hls_video(sys.argv[1], sys.argv[2])
sys.exit(0 if success else 1)
#!/usr/bin/env python3
"""
スクリーンショット抽出スクリプト
動画からスクリーンショットを抽出する。
- 一定間隔での抽出
- シーン変化検出での抽出(オプション)
Usage:
python screenshot_extractor.py <video_path> <output_dir> [--interval SECONDS] [--scene]
Example:
python screenshot_extractor.py "videos/01_intro.mp4" "screenshots/01" --interval 30
python screenshot_extractor.py "videos/01_intro.mp4" "screenshots/01" --scene
"""
import subprocess
import sys
import shutil
import argparse
from pathlib import Path
from typing import List, Optional
def get_video_duration(video_path: Path) -> float:
"""動画の長さを取得(秒)"""
cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
return float(result.stdout.strip())
def extract_title_frame(video_path: Path, output_dir: Path) -> Optional[Path]:
"""
タイトル画像を抽出(1秒目)
Returns:
出力ファイルパス(成功時)
"""
output_file = output_dir / "title.jpg"
cmd = [
"ffmpeg", "-y",
"-i", str(video_path),
"-ss", "00:00:01",
"-vframes", "1",
"-q:v", "2",
str(output_file)
]
try:
subprocess.run(cmd, capture_output=True, check=True)
if output_file.exists():
print(f"Title frame: {output_file.name}")
return output_file
except subprocess.CalledProcessError:
pass
return None
def extract_interval_frames(
video_path: Path,
output_dir: Path,
interval: int = 30
) -> List[Path]:
"""
一定間隔でフレームを抽出
Args:
video_path: 入力動画
output_dir: 出力ディレクトリ
interval: 抽出間隔(秒)
Returns:
抽出されたファイルパスのリスト
"""
pattern = str(output_dir / "frame_%03d.jpg")
# fps=1/interval で指定間隔ごとに抽出
cmd = [
"ffmpeg", "-y",
"-i", str(video_path),
"-vf", f"fps=1/{interval}",
"-q:v", "2",
pattern
]
try:
subprocess.run(cmd, capture_output=True, check=True)
except subprocess.CalledProcessError as e:
print(f"Error extracting frames: {e.stderr[:200] if e.stderr else 'Unknown'}")
return []
# 生成されたファイルを収集
frames = sorted(output_dir.glob("frame_*.jpg"))
print(f"Extracted {len(frames)} frames at {interval}s intervals")
return frames
def extract_scene_frames(
video_path: Path,
output_dir: Path,
threshold: float = 0.3
) -> List[Path]:
"""
シーン変化検出でフレームを抽出
Args:
video_path: 入力動画
output_dir: 出力ディレクトリ
threshold: シーン変化しきい値(0.0-1.0、小さいほど敏感)
Returns:
抽出されたファイルパスのリスト
"""
pattern = str(output_dir / "scene_%03d.jpg")
cmd = [
"ffmpeg", "-y",
"-i", str(video_path),
"-vf", f"select='gt(scene,{threshold})',showinfo",
"-vsync", "vfr",
"-q:v", "2",
pattern
]
try:
result = subprocess.run(cmd, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
print(f"Error detecting scenes: {e.stderr[:200] if e.stderr else 'Unknown'}")
return []
# 生成されたファイルを収集
frames = sorted(output_dir.glob("scene_*.jpg"))
print(f"Detected {len(frames)} scene changes (threshold: {threshold})")
return frames
def extract_timestamps_frames(
video_path: Path,
output_dir: Path,
timestamps: List[float]
) -> List[Path]:
"""
指定したタイムスタンプでフレームを抽出
Args:
video_path: 入力動画
output_dir: 出力ディレクトリ
timestamps: 抽出する時間(秒)のリスト
Returns:
抽出されたファイルパスのリスト
"""
frames = []
for i, ts in enumerate(timestamps):
output_file = output_dir / f"ts_{i:03d}_{int(ts):05d}.jpg"
# 時間を HH:MM:SS 形式に変換
hours = int(ts // 3600)
minutes = int((ts % 3600) // 60)
seconds = ts % 60
cmd = [
"ffmpeg", "-y",
"-ss", f"{hours:02d}:{minutes:02d}:{seconds:06.3f}",
"-i", str(video_path),
"-vframes", "1",
"-q:v", "2",
str(output_file)
]
try:
subprocess.run(cmd, capture_output=True, check=True)
if output_file.exists():
frames.append(output_file)
except subprocess.CalledProcessError:
print(f"Failed to extract frame at {ts}s")
print(f"Extracted {len(frames)} frames at specified timestamps")
return frames
def extract_phash_frames(
video: Path,
output_dir: Path,
sample_interval: int = 2,
phash_threshold: int = 8
) -> List[Path]:
"""
pHash(Perceptual Hash)方式でフレーム抽出
細かい間隔で候補フレームを抽出し、連続するフレームのpHash距離が
閾値を超えた場合のみ採用する(重複排除)。
Args:
video: 入力動画
output_dir: 出力ディレクトリ
sample_interval: 候補抽出間隔(秒、細かいほど精度↑、処理↑)
phash_threshold: pHashハミング距離の閾値(大きいほど選別厳しい)
Returns:
採用されたフレームファイルパスのリスト
"""
try:
import imagehash
from PIL import Image
except ImportError:
print("Error: imagehash and Pillow required for --phash mode")
print("Install: pip install imagehash Pillow")
return []
# 一時ディレクトリで候補フレームを抽出
import tempfile
temp_dir = Path(tempfile.mkdtemp(prefix="phash_candidates_"))
try:
pattern = str(temp_dir / "cand_%05d.jpg")
cmd = [
"ffmpeg", "-y",
"-i", str(video),
"-vf", f"fps=1/{sample_interval}",
"-q:v", "3",
"-loglevel", "error",
pattern
]
subprocess.run(cmd, check=True)
candidates = sorted(temp_dir.glob("cand_*.jpg"))
print(f"Extracted {len(candidates)} candidate frames at {sample_interval}s intervals")
# pHashでデデュープ
kept = []
prev_hash = None
for c in candidates:
try:
h = imagehash.phash(Image.open(c))
except Exception as e:
print(f"Failed to hash {c.name}: {e}")
continue
if prev_hash is None or (h - prev_hash) > phash_threshold:
kept.append(c)
prev_hash = h
print(f"Deduped: {len(kept)} unique frames (threshold={phash_threshold})")
# 採用分を出力ディレクトリに連番コピー
output_frames = []
for i, src in enumerate(kept, 1):
dst = output_dir / f"frame_{i:03d}.jpg"
shutil.copy2(src, dst)
output_frames.append(dst)
return output_frames
finally:
try:
shutil.rmtree(temp_dir)
except Exception:
pass
def extract_screenshots(
video_path: str,
output_dir: str,
interval: int = 30,
use_scene_detection: bool = False,
scene_threshold: float = 0.3,
include_title: bool = True,
use_phash: bool = False,
phash_sample_interval: int = 2,
phash_threshold: int = 8
) -> List[Path]:
"""
動画からスクリーンショットを抽出
Args:
video_path: 入力動画ファイル
output_dir: 出力ディレクトリ
interval: 抽出間隔(秒、シーン検出時は無視)
use_scene_detection: シーン検出を使用
scene_threshold: シーン変化しきい値
include_title: タイトル画像を含める
Returns:
抽出されたファイルパスのリスト
"""
video = Path(video_path)
output = Path(output_dir)
# ffmpegチェック
if not shutil.which("ffmpeg"):
print("Error: ffmpeg not found. Please install ffmpeg first.")
return []
if not video.exists():
print(f"Error: Video file not found: {video}")
return []
# 出力ディレクトリ作成
output.mkdir(parents=True, exist_ok=True)
duration = get_video_duration(video)
print(f"Video: {video.name}")
print(f"Duration: {duration:.1f}s ({duration/60:.1f} min)")
frames = []
# タイトル画像を抽出
if include_title:
title = extract_title_frame(video, output)
if title:
frames.append(title)
# メインの抽出処理
if use_phash:
phash_frames = extract_phash_frames(
video, output,
sample_interval=phash_sample_interval,
phash_threshold=phash_threshold
)
frames.extend(phash_frames)
# pHashで少なすぎる場合は間隔抽出も併用
if len(phash_frames) < 5:
print("Too few phash frames, falling back to interval...")
interval_frames = extract_interval_frames(video, output, interval)
frames.extend(interval_frames)
elif use_scene_detection:
scene_frames = extract_scene_frames(video, output, scene_threshold)
frames.extend(scene_frames)
# シーン検出で少なすぎる場合は間隔抽出も併用
if len(scene_frames) < 5:
print("Too few scenes detected, adding interval frames...")
interval_frames = extract_interval_frames(video, output, interval)
frames.extend(interval_frames)
else:
interval_frames = extract_interval_frames(video, output, interval)
frames.extend(interval_frames)
print(f"\nTotal: {len(frames)} screenshots extracted to {output}")
return frames
def main():
parser = argparse.ArgumentParser(
description="Extract screenshots from video"
)
parser.add_argument("video_path", help="Input video file")
parser.add_argument("output_dir", help="Output directory for screenshots")
parser.add_argument(
"--interval", "-i",
type=int,
default=30,
help="Extraction interval in seconds (default: 30)"
)
parser.add_argument(
"--scene", "-s",
action="store_true",
help="Use scene change detection"
)
parser.add_argument(
"--threshold", "-t",
type=float,
default=0.3,
help="Scene detection threshold 0.0-1.0 (default: 0.3)"
)
parser.add_argument(
"--no-title",
action="store_true",
help="Don't extract title frame"
)
parser.add_argument(
"--timestamps",
type=str,
help="Extract frames at specific timestamps (comma-separated seconds, e.g., '60,120,180')"
)
parser.add_argument(
"--phash",
action="store_true",
default=True,
help="Use perceptual hash dedup mode (DEFAULT - catches slide changes, removes duplicates)"
)
parser.add_argument(
"--no-phash",
dest="phash",
action="store_false",
help="Disable pHash mode and use the legacy interval/scene mode"
)
parser.add_argument(
"--phash-sample",
type=int,
default=2,
help="pHash candidate sampling interval in seconds (default: 2)"
)
parser.add_argument(
"--phash-threshold",
type=int,
default=8,
help="pHash hamming distance threshold 0-64 (default: 8, lower=more frames)"
)
args = parser.parse_args()
# タイムスタンプ指定モード
if args.timestamps:
video = Path(args.video_path)
output = Path(args.output_dir)
output.mkdir(parents=True, exist_ok=True)
timestamps = [float(t.strip()) for t in args.timestamps.split(",")]
print(f"Extracting {len(timestamps)} frames at specified timestamps...")
frames = extract_timestamps_frames(video, output, timestamps)
sys.exit(0 if frames else 1)
# 通常モード
frames = extract_screenshots(
video_path=args.video_path,
output_dir=args.output_dir,
interval=args.interval,
use_scene_detection=args.scene,
scene_threshold=args.threshold,
include_title=not args.no_title,
use_phash=args.phash,
phash_sample_interval=args.phash_sample,
phash_threshold=args.phash_threshold
)
sys.exit(0 if frames else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Groq Whisper 文字起こしスクリプト
音声ファイルをチャンク分割してGroq Whisper APIで文字起こしする。
3分チャンク + 並列処理で高速化。
Usage:
python transcribe.py <audio_path> <output_path>
python transcribe.py <audio_path> <output_path> --timestamps <ts_output_path>
Example:
python transcribe.py "audio/01.mp3" "transcripts/01.txt"
python transcribe.py "audio/01.mp3" "transcripts/01.txt" --timestamps "transcripts/01_ts.json"
Environment:
GROQ_API_KEY: Groq APIキー(必須)
"""
import json
import os
import sys
import time
import subprocess
import tempfile
import shutil
from pathlib import Path
from typing import List, Tuple, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
try:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
except ImportError:
print("Error: requests library not found")
print("Install: pip install requests")
sys.exit(1)
def get_api_key() -> str:
"""APIキーを取得(環境変数または.envファイル)"""
api_key = os.getenv("GROQ_API_KEY")
if not api_key:
# .envファイルを探す
env_paths = [
Path(__file__).parent.parent / ".env",
Path.home() / ".claude/skills/utage-manual/.env",
Path.cwd() / ".env",
]
for env_path in env_paths:
if env_path.exists():
with open(env_path) as f:
for line in f:
if line.startswith("GROQ_API_KEY="):
api_key = line.split("=", 1)[1].strip().strip('"').strip("'")
break
if api_key:
break
return api_key or ""
def create_session() -> requests.Session:
"""リトライ付きセッションを作成"""
sess = requests.Session()
retry = Retry(
total=3,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504]
)
adapter = HTTPAdapter(
max_retries=retry,
pool_connections=10,
pool_maxsize=10
)
sess.mount("https://", adapter)
sess.mount("http://", adapter)
return sess
def get_audio_duration(audio_path: Path) -> float:
"""音声ファイルの長さを取得(秒)"""
cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(audio_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
return float(result.stdout.strip())
def split_audio_to_chunks(
audio_path: Path,
chunk_seconds: int = 180,
temp_dir: Optional[Path] = None
) -> List[Path]:
"""
音声ファイルをチャンクファイルに分割
Args:
audio_path: 入力音声ファイル
chunk_seconds: チャンク秒数(デフォルト3分)
temp_dir: 一時ディレクトリ(Noneの場合自動作成)
Returns:
チャンクファイルパスのリスト
"""
if temp_dir is None:
temp_dir = Path(tempfile.mkdtemp(prefix="utage_chunks_"))
else:
temp_dir.mkdir(parents=True, exist_ok=True)
# 音声長を取得
duration = get_audio_duration(audio_path)
num_chunks = int(duration / chunk_seconds) + (1 if duration % chunk_seconds > 0 else 0)
print(f"Audio duration: {duration:.1f}s, splitting into {num_chunks} chunks")
# ffmpeg segmentモードで分割
pattern = str(temp_dir / "chunk_%04d.mp3")
cmd = [
"ffmpeg", "-y",
"-i", str(audio_path),
"-f", "segment",
"-segment_time", str(chunk_seconds),
"-vn",
"-ar", "16000",
"-ac", "1",
"-b:a", "64k",
"-acodec", "libmp3lame",
"-reset_timestamps", "1",
"-loglevel", "error",
pattern
]
subprocess.run(cmd, capture_output=True, check=True)
# 生成されたチャンクを収集
chunks = []
for i in range(num_chunks):
chunk_path = temp_dir / f"chunk_{i:04d}.mp3"
if chunk_path.exists():
chunks.append(chunk_path)
print(f"Created {len(chunks)} chunk files")
return chunks
def transcribe_chunk(
session: requests.Session,
api_key: str,
chunk_path: Path,
model: str = "whisper-large-v3",
language: str = "ja",
max_retries: int = 3,
response_format: str = "text"
):
"""
単一チャンクを文字起こし
Args:
session: リクエストセッション
api_key: Groq APIキー
chunk_path: チャンクファイルパス
model: Whisperモデル
language: 言語コード
max_retries: リトライ回数
response_format: "text" or "verbose_json"
Returns:
文字起こしテキスト(text形式)またはdict(verbose_json形式)
"""
url = "https://api.groq.com/openai/v1/audio/transcriptions"
for attempt in range(max_retries):
try:
with open(chunk_path, "rb") as f:
files = {"file": (chunk_path.name, f, "audio/mpeg")}
headers = {"Authorization": f"Bearer {api_key}"}
data = {
"model": model,
"response_format": response_format,
"language": language,
}
if response_format == "verbose_json":
data["timestamp_granularities[]"] = "segment"
resp = session.post(
url,
headers=headers,
files=files,
data=data,
timeout=120
)
if resp.status_code == 200:
if response_format == "verbose_json":
return resp.json()
return resp.text.strip()
if resp.status_code == 429:
wait = min(30, 2 ** attempt)
print(f"Rate limit, waiting {wait}s...")
time.sleep(wait)
elif resp.status_code in [500, 502, 503, 504]:
wait = min(30, 5 * (2 ** attempt))
print(f"Server error {resp.status_code}, waiting {wait}s...")
time.sleep(wait)
else:
print(f"API error {resp.status_code}: {resp.text[:200]}")
except Exception as e:
print(f"Request failed (attempt {attempt+1}/{max_retries}): {e}")
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
return {} if response_format == "verbose_json" else ""
def transcribe_audio(
audio_path: str,
output_path: str,
chunk_seconds: int = 180,
max_workers: int = 10,
model: str = "whisper-large-v3",
language: str = "ja",
timestamps_path: Optional[str] = None
) -> Tuple[bool, str]:
"""
音声ファイルを文字起こし
Args:
audio_path: 入力音声ファイル
output_path: 出力テキストファイル
chunk_seconds: チャンク秒数
max_workers: 並列ワーカー数
model: Whisperモデル
language: 言語
timestamps_path: タイムスタンプJSON出力パス(Noneの場合は出力しない)
Returns:
(成功フラグ, 文字起こしテキスト)
"""
audio = Path(audio_path)
output = Path(output_path)
want_timestamps = timestamps_path is not None
if not audio.exists():
print(f"Error: Audio file not found: {audio}")
return False, ""
api_key = get_api_key()
if not api_key:
print("Error: GROQ_API_KEY not found")
print("Set environment variable or add to .env file")
return False, ""
output.parent.mkdir(parents=True, exist_ok=True)
if timestamps_path:
Path(timestamps_path).parent.mkdir(parents=True, exist_ok=True)
start_time = time.time()
temp_dir = None
try:
# チャンク分割
chunks = split_audio_to_chunks(audio, chunk_seconds)
temp_dir = chunks[0].parent if chunks else None
total_chunks = len(chunks)
# 動的並列数調整
if total_chunks <= 10:
workers = total_chunks
elif total_chunks <= 30:
workers = min(max_workers, 10)
elif total_chunks <= 50:
workers = min(max_workers, 8)
else:
workers = min(max_workers, 5)
print(f"Processing {total_chunks} chunks with {workers} workers")
# セッション作成
session = create_session()
# 並列処理
resp_format = "verbose_json" if want_timestamps else "text"
results = [None] * total_chunks
completed = 0
def process_chunk(idx: int, chunk_path: Path):
result = transcribe_chunk(
session, api_key, chunk_path, model, language,
response_format=resp_format
)
try:
chunk_path.unlink()
except:
pass
return idx, result
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {
executor.submit(process_chunk, i, chunk): i
for i, chunk in enumerate(chunks)
}
for future in as_completed(futures):
try:
idx, result = future.result()
results[idx] = result
completed += 1
print(f"Completed {completed}/{total_chunks}")
except Exception as e:
idx = futures[future]
results[idx] = "" if not want_timestamps else {}
completed += 1
print(f"Chunk {idx} failed: {e}")
if want_timestamps:
# verbose_json: テキスト結合 + タイムスタンプJSON生成
text_parts = []
all_segments = []
for idx, result in enumerate(results):
if not result:
continue
text_parts.append(result.get("text", ""))
chunk_offset = idx * chunk_seconds
for seg in result.get("segments", []):
all_segments.append({
"start": round(seg.get("start", 0) + chunk_offset, 2),
"end": round(seg.get("end", 0) + chunk_offset, 2),
"text": seg.get("text", "").strip()
})
combined_text = "\n".join([t for t in text_parts if t])
# タイムスタンプJSONを保存
ts_data = {
"segments": all_segments,
"total_segments": len(all_segments)
}
Path(timestamps_path).write_text(
json.dumps(ts_data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
print(f" Timestamps: {timestamps_path} ({len(all_segments)} segments)")
else:
# text形式: そのまま結合
combined_text = "\n".join([r for r in results if r])
# ファイルに保存
output.write_text(combined_text, encoding="utf-8")
elapsed = time.time() - start_time
duration = get_audio_duration(audio)
speed = duration / elapsed if elapsed > 0 else 0
print(f"Transcription complete!")
print(f" Duration: {duration:.1f}s")
print(f" Time: {elapsed:.1f}s")
print(f" Speed: {speed:.1f}x realtime")
print(f" Output: {output}")
return True, combined_text
finally:
# 一時ディレクトリを削除
if temp_dir and temp_dir.exists():
try:
shutil.rmtree(temp_dir)
except:
pass
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Groq Whisper文字起こし")
parser.add_argument("audio_path", help="入力音声ファイル")
parser.add_argument("output_path", help="出力テキストファイル")
parser.add_argument("--timestamps", help="タイムスタンプJSON出力パス", default=None)
args = parser.parse_args()
success, _ = transcribe_audio(args.audio_path, args.output_path, timestamps_path=args.timestamps)
sys.exit(0 if success else 1)
マニュアル文章スタイルガイド
基本原則
マニュアルは「読み物」として読みやすくする。箇条書きや表の羅列ではなく、文章で流れるように説明する。
---
避けるべきスタイル
❌ 箇条書きの多用
## 特徴
- 特徴1
- 特徴2
- 特徴3
- 特徴4
- 特徴5❌ 表の乱用
| 項目 | 内容 |
|---|---|
| A | Aの説明 |
| B | Bの説明 |
| C | Cの説明 |❌ 情報の羅列
## 第9章の内容
第9章では魔法台本プロンプトを学びます。
- コピー3回で台本作成
- 全3種類のプロンプト
- 無限に生成可能---
推奨スタイル
✅ 文章で流れるように説明
## 特徴
この機能の最大の特徴は**特徴1**です。さらに特徴2の機能も備えており、特徴3と組み合わせることで、より効果的に活用できます。
特徴4は初心者にも使いやすく設計されており、特徴5によって上級者のニーズにも対応しています。✅ 動画の語り口調を活かす
動画内で話者が使っている言い回しをそのまま活かす:
「講座はいいから早く台本が書きたい」「俺の仕事はAIではなく動画を作ることだ」という方は、**第9章から始めてください。**✅ 読者への問いかけ
## あなたはどちらのタイプ?
今すぐプロンプトを使いたい方と、基礎からしっかり学びたい方では、スタート地点が異なります。✅ 段落で自然に読める形式
**第9章「魔法台本プロンプト」** では、コピー3回で分析からプロンプト出力まで完了し、そのプロンプトで台本が無限に生成できる夢のようなプロンプトを手に入れられます。全3種類あります。
**第10章「長尺→ショートプロンプト」** では、長尺の台本から必要要素を選んで抜き出し、ショート動画用の台本に変換できます。バズる冒頭のフック知識も含まれています。✅ 最後に明確なアクションを提示
## 次にやること
**即実践コースの方** → 第9章:魔法台本メタプロンプトへ進む
**着実コースの方** → 第1章:なぜ今生成AIを学ばないといけないのかへ進む---
マニュアル構成テンプレート
# 第X章:[タイトル]

---
## はじめに
[この章で学ぶ内容の概要を2〜3文で説明]

[画像の内容に触れながら説明を続ける]
---
## [セクション1のタイトル]
[文章で説明。画像がある場合は画像の内容と整合性を取る]

[画像に関連する説明を続ける]
---
## [セクション2のタイトル]
...
---
## まとめ / 次にやること
[この章で学んだことの要約]
[次のアクションを明確に提示]
---
*本マニュアルは「[講座名]」第X章の動画内容(X分X秒)を基に作成されました。*---
画像の使い方(重要)
MANDATORY: 画像は積極的に使用する。スクリーンショットがあるのに貼らないのはNG。
ルール
1. 各セクションに最低1枚は画像を入れる(関連する画像がある場合) 2. 画像の内容に言及する:画像を貼ったら、その画像に何が写っているか文章で触れる 3. 画像→説明の流れ:画像を先に見せてから、その内容を文章で補足する
悪い例
## AIマインドについて
AIマインドとは脱AI責思考です。AIのせいにしないことが大切です。良い例
## AIマインドについて

上の画像にあるように、AIマインドとは「脱AI責思考」、つまりAIのせいにしないマインドのことです。このマインドがなければ、ChatGPT等のAIを扱っても理想の結果を得られません。---
セクション間の繋がり(重要)
MANDATORY: セクションが唐突に切り替わらないよう、接続を意識する。
ルール
1. 前のセクションを受けて次を始める 2. 「では」「さて」「ここで」などの接続表現を使う 3. 前のセクションの内容を軽く振り返ってから次へ進む
悪い例
## 破壊力
生成AIは破壊力がMAXです。
---
## 緊急度MAX
今学ばないと遅れます。良い例
## 破壊力
生成AIは破壊力がMAXです。仕事のルールそのものを変えてしまいました。
---
## 緊急度MAX
破壊力がわかったところで、次は「なぜ今なのか」という緊急度についてです。
生成AIは急速に進化中で、今すぐ学ばなければ他の人や企業に遅れを取ります。---
チェックリスト
マニュアル作成後、以下を確認:
- [ ] 箇条書きが3つ以上連続していないか
- [ ] 表が必要最小限か(2つ以上の表が連続していないか)
- [ ] 各セクションが文章として読めるか
- [ ] 動画の語り口調が活きているか
- [ ] 読者への問いかけがあるか
- [ ] 最後に明確なアクションがあるか
- [ ] 各セクションに関連画像が入っているか
- [ ] 画像の内容に文章で言及しているか
- [ ] セクション間が自然に繋がっているか