leave-everything commited on
Commit
acaad56
·
verified ·
1 Parent(s): fe45bce

Upload 6 files

Browse files
Files changed (3) hide show
  1. README.md +141 -160
  2. app.py +5 -406
  3. 実装ステップ.md +709 -0
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: 語呂羽丸五郎 - スライド音声動画生成
3
  emoji: 🎬
4
  colorFrom: blue
5
  colorTo: purple
@@ -9,109 +9,28 @@ app_file: app.py
9
  pinned: true
10
  ---
11
 
12
- # 🎬 語呂羽丸五郎 - スライド音声動画生成API
13
 
14
- 歴史学習用スライドから音声付き動画を生成するAPIです
15
 
16
  ## 🌟 機能
17
 
18
- ### V2.0: スライドデータ → 音声付き動画(完成・2025-10-10)
 
 
 
 
 
19
 
20
- - **スライドデータJSON入力**: Difyワから送信されるライドデータを受信
21
- - **Gemini TTS音声生成**: Gemini 2.5 Flash TTS(Charon音声、1.25倍速)
22
- - **音声付き動画生成**: moviepyで音声と画像を同期した動画を生成
23
- - **HFップロード**: 動画(videos/)と音声(audios/)自動保存
24
- - **URL返却**: アクセス可能なURL返却
25
-
26
- ### V1.0: PDF → スライドショー動画(✅ 完成)
27
-
28
- - **PDF URL入力**: 指定URLからPDFをダウンロード
29
- - **PDF→画像変換**: pdf2imageで各ページを画像化
30
- - **スライドショー動画生成**: 各ページを指定秒数表示
31
- - **2ページ目画像保存**: Difyワークフロー用
32
- - **HFアップロード**: 動画と画像を自動保存
33
-
34
- ---
35
 
36
  ## 🔌 API仕様
37
 
38
- ### V2.0: `POST /api/slidedata-to-video`
39
-
40
- #### リクエスト
41
- ```json
42
- {
43
- "slide_data": [
44
- {
45
- "type": "title",
46
- "title": "語呂羽丸五郎の歴史学習",
47
- "date": "2025.10.10"
48
- },
49
- {
50
- "type": "imageText",
51
- "title": "239年の出来事",
52
- "subhead": "239年:ふみくれ卑弥呼に金印",
53
- "image": "https://...",
54
- "points": [],
55
- "notes": "239年、ふみくれ卑弥呼に金印。239年、ふみくれ卑弥呼に金印。"
56
- },
57
- {
58
- "type": "imageText",
59
- "title": "239年の出来事",
60
- "subhead": "239年:ふみくれ卑弥呼に金印",
61
- "image": "https://...",
62
- "points": ["邪馬台国の女王卑弥呼が魏に使いを送った。", "魏の皇帝から金印と銅鏡を授けられた。"],
63
- "notes": "..."
64
- },
65
- {
66
- "type": "closing",
67
- "notes": "今日もよく頑張りました。復習を忘れずに!"
68
- }
69
- ]
70
- }
71
- ```
72
-
73
- **パラメータ:**
74
- - `slide_data` (array, 必須): スライドデータ配列
75
- - `type`: "title" | "imageText" | "closing"
76
- - `title`, `subhead`, `image`, `points`, `notes`, `date`: スライド種別により異なる
77
-
78
- #### レスポンス
79
- ```json
80
- {
81
- "status": "success",
82
- "video_url": "https://huggingface.co/datasets/.../videos/slidedata_video_20251010_085827_e3c2bf55.mp4",
83
- "page2_image_url": null,
84
- "audio_urls": [
85
- {
86
- "slide_index": 0,
87
- "slide_type": "title",
88
- "audio_url": "https://huggingface.co/datasets/.../audios/slide_00_title_20251010_085823_0ad632bf.wav",
89
- "duration": 3.17675,
90
- "text": "語呂羽丸五郎の歴史学習"
91
- }
92
- ],
93
- "message": "音声付き動画の生成とアップロードに成功しました",
94
- "total_slides": 1,
95
- "video_duration": 3.77675
96
- }
97
- ```
98
-
99
- **フィールド:**
100
- - `status`: "success" | "error"
101
- - `video_url`: 生成された動画のURL
102
- - `page2_image_url`: null(V2.0では未使用)
103
- - `audio_urls`: 各スライドの音声情報配列
104
- - `slide_index`: スライド番号
105
- - `slide_type`: スライド種別
106
- - `audio_url`: 音声ファイルURL(WAV、24kHz、モノラル、1.25倍速)
107
- - `duration`: 音声長さ(秒)
108
- - `text`: 音声テキスト
109
- - `total_slides`: 総スライド数
110
- - `video_duration`: 動画総再生時間(秒)
111
-
112
- ---
113
-
114
- ### V1.0: `POST /api/pdf-to-video`
115
 
116
  #### リクエスト
117
  ```json
@@ -131,18 +50,25 @@ pinned: true
131
  ```json
132
  {
133
  "status": "success",
134
- "video_url": "https://huggingface.co/datasets/.../videos/video_20250107_123456_abc123.mp4",
135
- "page2_image_url": "https://huggingface.co/datasets/.../images/image_20250107_123456_def456.jpg",
136
  "message": "動画の生成とアップロードに成功しました",
137
  "total_pages": 10,
138
  "video_duration": 50.0
139
  }
140
  ```
141
 
142
- ---
 
 
 
 
 
 
 
143
 
144
- ### ヘルスチェック: `GET /health`
145
 
 
146
  ```json
147
  {
148
  "status": "healthy",
@@ -151,56 +77,50 @@ pinned: true
151
  }
152
  ```
153
 
154
- ---
155
-
156
  ## 🛠️ 技術スタック
157
 
158
- ### V2.0
159
- - **FastAPI + Gradio 4.19.2**: WebUI/API
160
- - **Gemini 2.5 Flash TTS**: 音声生成(Charon音声)
161
- - **moviepy 1.0.3**: 音声付き動画生成
162
- - **numpy**: 音声1.25倍速処理
163
- - **huggingface_hub**: ストレージ
164
-
165
- ### V1.0
166
  - **pdf2image**: PDF→画像変換
167
  - **OpenCV (cv2)**: 動画生成エンジン
 
168
  - **Pillow**: 画像処理
169
-
170
- ---
171
 
172
  ## ⚙️ 環境変数
173
 
 
 
174
  | 変数名 | 必須 | デフォルト値 | 説明 |
175
  |--------|------|--------------|------|
176
- | `GEMINI_TOKEN` | ✅ | - | Gemini API認証トークン(V2.0) |
177
  | `HF_TOKEN` | ✅ | - | Hugging Face認証トークン |
178
- | `HF_REPO_ID` | ❌ | `tomo2chin2/SUPER_TENSAI_JIN` | データセットリポジトリID |
179
 
180
- ---
 
 
 
 
 
 
 
181
 
182
  ## 🚀 使用方法
183
 
184
- ### V2.0: curlの例
185
 
186
- ```bash
187
- curl -X POST "https://tomo2chin2-pdf-slideshow.hf.space/api/slidedata-to-video" \
188
- -H "Content-Type: application/json" \
189
- -d '{
190
- "slide_data": [
191
- {
192
- "type": "title",
193
- "title": "語呂羽丸五郎の歴史学習",
194
- "date": "2025.10.10"
195
- }
196
- ]
197
- }'
198
- ```
199
 
200
- ### V1.0: curlの例
 
 
201
 
202
  ```bash
203
- curl -X POST "https://tomo2chin2-pdf-slideshow.hf.space/api/pdf-to-video" \
204
  -H "Content-Type: application/json" \
205
  -d '{
206
  "pdf_url": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
@@ -209,50 +129,111 @@ curl -X POST "https://tomo2chin2-pdf-slideshow.hf.space/api/pdf-to-video" \
209
  }'
210
  ```
211
 
212
- ---
213
 
214
- ## 📊 V2.0処理フロー
 
215
 
 
 
 
 
 
 
 
 
 
 
 
216
  ```
217
- 1. スライドデータJSON受信
218
-
219
- 2. 各スライドから音声テキスト抽出
220
- - タイトル: titleフィールド
221
- - 画像のみ: "年号年、語呂合わせ。" × 2回
222
- - 画像+テキスト: pointsを結合
223
- - クロージング: notesフィールド
224
-
225
- 3. Gemini TTS音声生成(Charon音声)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
- 4. 音声1.25倍速処理numpy
228
 
229
- 5. 音声長さ測定
230
 
231
- 6. moviepy動画生成
232
- - 音声 + 0.6余白(前後0.3秒ずつ)
233
- - 720p、30fps、H.264 + AAC
234
 
235
- 7. HFアップロード(videos/, audios/)
236
 
237
- 8. URL返却
238
  ```
239
 
240
- ---
241
-
242
- ## 📚 ドキュメント
243
 
244
- - [V2.0開発ログ](docs/V2.0_DEVELOPMENT_LOG.md) - 開発履歴
245
- - [V2.0開発計画](docs/V2.0_DEVELOPMENT_PLAN.md) - 計画書
246
- - [V3.0構想](docs/V3.0構想.txt) - 次期バージョン計画
247
 
248
- ---
 
249
 
250
  ## ⚠️ 制限事項
251
 
252
- - **V2.0**: Gemini API タイムアウト60秒)に注意
253
- - **V1.0**: PDFサ20ページ以上は処理時間がかかる
254
- - **共通**: 同時リクエストには対応していない
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
  ---
257
 
258
- **最終更新**: 2025-10-10
 
1
  ---
2
+ title: PDF to Video Converter
3
  emoji: 🎬
4
  colorFrom: blue
5
  colorTo: purple
 
9
  pinned: true
10
  ---
11
 
12
+ # 📄 PDF to Video Converter 🎬
13
 
14
+ PDFファイルをスライドショー動画に変換するAPIです。指定されたURLからPDFをダウンロードし、各ページを画像化して、1ページあたり指定秒数のスライドショー動画を生成します。
15
 
16
  ## 🌟 機能
17
 
18
+ ### ✅ 主要機能
19
+ - **PDF URLからのダウンロード**: HTTPリクエストで指定されたURLからPDFを取得
20
+ - **PDF→画像変換**: pdf2imageを使用して各ページを高品質な画像に変換
21
+ - **スライドショー動画生成**: 各画像を指定秒数(デフォルト5秒)表示する動画を作成
22
+ - **Hugging Faceアップロード**: 生成した動画をデータセットリポジトリに自動保存
23
+ - **URL返却**: アップロードされた動画のアクセス可能なURLを返却
24
 
25
+ ### 🎨 ユザーインターフース
26
+ - **Gradio UI**: 直感的なWebインターフェース
27
+ - **FastAPI**: RESTful APIエンドポイント
28
+ - **ルタイム進捗表示**: 処理状況確認可能
29
+ - **動画プレビュー**: 生成された動画その場で確認
 
 
 
 
 
 
 
 
 
 
30
 
31
  ## 🔌 API仕様
32
 
33
+ ### エンドポイント: `POST /api/pdf-to-video`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  #### リクエスト
36
  ```json
 
50
  ```json
51
  {
52
  "status": "success",
53
+ "video_url": "https://huggingface.co/datasets/username/repo/resolve/main/videos/video_20250107_123456_abc123.mp4",
 
54
  "message": "動画の生成とアップロードに成功しました",
55
  "total_pages": 10,
56
  "video_duration": 50.0
57
  }
58
  ```
59
 
60
+ **フィールド:**
61
+ - `status` (string): 処理結果("success" または "error")
62
+ - `video_url` (string): 生成された動画のURL
63
+ - `message` (string): 処理結果メッセージ
64
+ - `total_pages` (integer): PDFの総ページ数
65
+ - `video_duration` (float): 動画の総再生時間(秒)
66
+
67
+ ### エンドポイント: `GET /health`
68
 
69
+ ヘルスチェック用エンドポイント
70
 
71
+ #### レスポンス
72
  ```json
73
  {
74
  "status": "healthy",
 
77
  }
78
  ```
79
 
 
 
80
  ## 🛠️ 技術スタック
81
 
82
+ - **Gradio 4.19.2**: WebUI/SDK
83
+ - **FastAPI**: REST APIフレームワーク
 
 
 
 
 
 
84
  - **pdf2image**: PDF→画像変換
85
  - **OpenCV (cv2)**: 動画生成エンジン
86
+ - **huggingface_hub**: データセットアップロード
87
  - **Pillow**: 画像処理
88
+ - **requests**: HTTP通信
 
89
 
90
  ## ⚙️ 環境変数
91
 
92
+ 以下の環境変数を設定する必要があります:
93
+
94
  | 変数名 | 必須 | デフォルト値 | 説明 |
95
  |--------|------|--------------|------|
 
96
  | `HF_TOKEN` | ✅ | - | Hugging Face認証トークン |
97
+ | `HF_REPO_ID` | ❌ | `tomo2chin2/video-storage` | データセットリポジトリID |
98
 
99
+ ### HF_TOKENの取得方法
100
+
101
+ 1. [Hugging Face](https://huggingface.co/)にログイン
102
+ 2. Settings → Access Tokens に移動
103
+ 3. "New token" をクリック
104
+ 4. Write権限を付与してトークンを生成
105
+ 5. 生成されたトークンをコピー
106
+ 6. Space設定の "Repository secrets" に追加
107
 
108
  ## 🚀 使用方法
109
 
110
+ ### Web UIから使用
111
 
112
+ 1. Space URLにアクセス
113
+ 2. PDF URLを入力欄に貼り付け
114
+ 3. 表示秒数とDPIを調整(任意)
115
+ 4. 「🎬 動画生成」ボタンをクリック
116
+ 5. 生成された動画URLをコピー
 
 
 
 
 
 
 
 
117
 
118
+ ### APIから使用
119
+
120
+ #### curlの例
121
 
122
  ```bash
123
+ curl -X POST "https://your-space.hf.space/api/pdf-to-video" \
124
  -H "Content-Type: application/json" \
125
  -d '{
126
  "pdf_url": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
 
129
  }'
130
  ```
131
 
132
+ #### Pythonの例
133
 
134
+ ```python
135
+ import requests
136
 
137
+ response = requests.post(
138
+ "https://your-space.hf.space/api/pdf-to-video",
139
+ json={
140
+ "pdf_url": "https://example.com/sample.pdf",
141
+ "duration_per_page": 5,
142
+ "dpi": 150
143
+ }
144
+ )
145
+
146
+ result = response.json()
147
+ print(f"動画URL: {result['video_url']}")
148
  ```
149
+
150
+ #### JavaScriptの例
151
+
152
+ ```javascript
153
+ fetch('https://your-space.hf.space/api/pdf-to-video', {
154
+ method: 'POST',
155
+ headers: {
156
+ 'Content-Type': 'application/json',
157
+ },
158
+ body: JSON.stringify({
159
+ pdf_url: 'https://example.com/sample.pdf',
160
+ duration_per_page: 5,
161
+ dpi: 150
162
+ })
163
+ })
164
+ .then(response => response.json())
165
+ .then(data => console.log('動画URL:', data.video_url));
166
+ ```
167
+
168
+ ## 📊 処理フロー
169
+
170
+ ```
171
+ 1. PDF URLダウンロード
172
 
173
+ 2. PDF→画像変換pdf2image
174
 
175
+ 3. 画像サイズ統一(Pillow)
176
 
177
+ 4. 動画生成(OpenCV)
178
+ - 各ページを指定数表示
179
+ - 30fps
180
 
181
+ 5. Hugging Faceアップロード
182
 
183
+ 6. URL返却
184
  ```
185
 
186
+ ## 🧪 テスト用サンプルPDF
 
 
187
 
188
+ 以下のURLでテスト可能です:
 
 
189
 
190
+ - **W3C ダミーPDF**: `https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf`
191
+ - **小サイズPDF**: 処理時間が短く、動作確認に最適
192
 
193
  ## ⚠️ 制限事項
194
 
195
+ 1. **PDFサイズ**: 大きなPDF20ページ以上は処理時間がかかります
196
+ 2. **タイムアウト**: Hugging Face Spacesのタムアウト制限〜60秒)に注意
197
+ 3. **同時リクエスト**: 大量の同時リクエストには対応していません
198
+ 4. **ファイル形式**: PDFのみ対応(画像ファイルは非対応)
199
+
200
+ ## 🔧 トラブルシューティング
201
+
202
+ ### エラー: "HuggingFace API が初期化されていません"
203
+ → `HF_TOKEN` 環境変数が設定されているか確認してください
204
+
205
+ ### エラー: "PDFのダウンロードに失敗しました"
206
+ → PDF URLが正しいか、アクセス可能か確認してください
207
+
208
+ ### エラー: "PDFの画像変換に失敗しました"
209
+ → PDFが破損していないか確認してください
210
+
211
+ ### 動画が生成されない
212
+ → ログを確認し、どの段階でエラーが発生しているか特定してください
213
+
214
+ ## 📁 ファイル構成
215
+
216
+ ```
217
+ majin/v4_spinoff/HUG/
218
+ ├── app.py # メインアプリケーション
219
+ ├── requirements.txt # Python依存パッケージ
220
+ ├── packages.txt # システムパッケージ
221
+ ├── README.md # このファイル
222
+ └── 実装ステップ.md # 詳細な実装手順
223
+ ```
224
+
225
+ ## 🤝 貢献
226
+
227
+ プルリクエストやイシューの報告を歓迎します。
228
+
229
+ ## 📜 ライセンス
230
+
231
+ MIT License
232
+
233
+ ## 📞 連絡先
234
+
235
+ 問題や質問がある場合は、Issueを作成してください。
236
 
237
  ---
238
 
239
+ **最終更新**: 2025-10-07
app.py CHANGED
@@ -7,7 +7,7 @@ import gradio as gr
7
  from fastapi import FastAPI, HTTPException
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from pydantic import BaseModel, HttpUrl
10
- from typing import Optional, Union, List, Dict
11
  import requests
12
  import tempfile
13
  import os
@@ -16,7 +16,6 @@ import numpy as np
16
  from datetime import datetime
17
  import uuid
18
  from pathlib import Path
19
- from concurrent.futures import ThreadPoolExecutor, as_completed
20
 
21
  # 画像・動画処理ライブラリ
22
  from pdf2image import convert_from_path
@@ -31,11 +30,6 @@ from huggingface_hub import HfApi, login
31
  logging.basicConfig(level=logging.INFO)
32
  logger = logging.getLogger(__name__)
33
 
34
- MAX_EDUCATION_TTS_WORKERS = max(
35
- 1,
36
- int(os.getenv("EDUCATION_TTS_MAX_WORKERS", "3")),
37
- )
38
-
39
  # ==============================
40
  # リクエスト/レスポンスモデル
41
  # ==============================
@@ -80,29 +74,6 @@ class AudioVideoResponse(BaseModel):
80
  total_slides: Optional[int] = None
81
  video_duration: Optional[float] = None
82
 
83
- # ==============================
84
- # 賢杉賢太郎連携バージョン - 追加モデル
85
- # ==============================
86
-
87
- class EducationNotesItem(BaseModel):
88
- """賢杉賢太郎: notes配列要素"""
89
- slide_index: Optional[int] = None
90
- text: str
91
- speaking_rate: Optional[float] = 1.25
92
- padding_seconds: Optional[float] = None
93
-
94
- class EducationPlaybackPolicy(BaseModel):
95
- """賢杉賢太郎: 再生ポリシー"""
96
- match_audio_length: bool = True
97
- fallback_seconds_per_slide: float = 6.0
98
- padding_seconds: float = 0.6
99
-
100
- class EducationVideoRequest(BaseModel):
101
- """賢杉賢太郎連携バージョン - notesをそのまま動画化"""
102
- pdf_url: str
103
- notes: Union[str, List[Union[str, Dict]]]
104
- playback_policy: Optional[EducationPlaybackPolicy] = None
105
-
106
  # ==============================
107
  # URL前処理ユーティリティ
108
  # ==============================
@@ -286,68 +257,6 @@ def extract_audio_text_v2(slide: dict, slide_index: int, history: list) -> str:
286
  return ""
287
 
288
 
289
- def normalize_notes_payload(notes_payload: Union[str, List[Union[str, Dict]]]) -> List[dict]:
290
- """
291
- 賢杉賢太郎用notesペイロードを正規化
292
-
293
- Args:
294
- notes_payload: list もしくは JSON文字列
295
-
296
- Returns:
297
- list[dict]: slide_index / text / speaking_rate / padding_seconds を含む辞書配列
298
- """
299
- import json
300
-
301
- if isinstance(notes_payload, str):
302
- try:
303
- raw_notes = json.loads(notes_payload)
304
- except json.JSONDecodeError as exc:
305
- raise ValueError(f"notesのJSON解析に失敗しました: {exc}")
306
- else:
307
- raw_notes = notes_payload or []
308
-
309
- normalized: List[dict] = []
310
-
311
- for idx, item in enumerate(raw_notes):
312
- if isinstance(item, dict):
313
- slide_index = item.get("slide_index", idx)
314
- text = str(item.get("text", "")).strip()
315
- speaking_rate = item.get("speaking_rate", 1.25)
316
- padding = item.get("padding_seconds")
317
- else:
318
- slide_index = idx
319
- text = str(item).strip()
320
- speaking_rate = 1.25
321
- padding = None
322
-
323
- try:
324
- slide_index = int(slide_index)
325
- except (TypeError, ValueError):
326
- slide_index = idx
327
-
328
- try:
329
- speaking_rate = float(speaking_rate) if speaking_rate is not None else 1.0
330
- except (TypeError, ValueError):
331
- speaking_rate = 1.0
332
- if speaking_rate <= 0:
333
- speaking_rate = 1.0
334
-
335
- if padding is not None:
336
- try:
337
- padding = float(padding)
338
- except (TypeError, ValueError):
339
- padding = None
340
-
341
- normalized.append({
342
- "slide_index": slide_index,
343
- "text": text,
344
- "speaking_rate": speaking_rate,
345
- "padding_seconds": padding
346
- })
347
-
348
- return normalized
349
-
350
-
351
  def convert_pil_to_array(pil_image: Image.Image, target_size: tuple) -> np.ndarray:
352
  """
353
  PIL ImageをNumPy配列に変換し、指定サイズにリサイズ
@@ -375,28 +284,20 @@ def convert_pil_to_array(pil_image: Image.Image, target_size: tuple) -> np.ndarr
375
  # V2.0: Gemini TTS音声生成
376
  # ==============================
377
 
378
- def generate_audio_with_gemini(
379
- audio_text: str,
380
- gemini_token: str,
381
- model: str = "gemini-2.5-pro-preview-tts",
382
- ) -> bytes:
383
  """
384
  Gemini REST APIでテキストから音声を生成
385
 
386
  Args:
387
  audio_text: 読み上げるテキスト
388
  gemini_token: GEMINI_TOKEN環境変数
389
- model: 利用するGemini TTSモデルID
390
 
391
  Returns:
392
  WAVバイナリデータ(24kHz PCM16)
393
  """
394
  import base64
395
 
396
- url = (
397
- "https://generativelanguage.googleapis.com/v1beta/models/"
398
- f"{model}:generateContent?key={gemini_token}"
399
- )
400
 
401
  headers = {
402
  "Content-Type": "application/json"
@@ -428,10 +329,10 @@ def generate_audio_with_gemini(
428
  }
429
  }
430
 
431
- logger.info(f"Gemini TTS API呼び出し: {len(audio_text)}文字, model={model}")
432
  logger.info(f"Payload: {payload}")
433
 
434
- response = requests.post(url, json=payload, headers=headers, timeout=120)
435
 
436
  # エラーレスポンスの詳細をログ出力
437
  if response.status_code != 200:
@@ -946,254 +847,6 @@ def create_video_with_audio_from_slides_v2(
946
  except Exception as e:
947
  logger.warning(f"動画ファイル削除エラー: {e}")
948
 
949
-
950
- def create_video_with_notes(
951
- pdf_url: str,
952
- notes_payload: Union[str, List[Union[str, Dict]]],
953
- gemini_token: str,
954
- playback_policy: Optional[dict] = None,
955
- progress_callback=None
956
- ) -> tuple:
957
- """
958
- 賢杉賢太郎連携バージョン:
959
- notesフィールド(スピーカーノート)から音声付き動画を生成する。
960
-
961
- Args:
962
- pdf_url: GASが生成したPDFのURL
963
- notes_payload: notes配列(list or JSON string)
964
- gemini_token: Gemini TTS用トークン
965
- playback_policy: 再生ポリシー辞書
966
- progress_callback: Gradio用進捗更新
967
-
968
- Returns:
969
- tuple: (video_url, page2_image_url, audio_info_list, total_slides, total_duration)
970
- """
971
- pdf_path = None
972
- audio_files: List[str] = []
973
- video_path = None
974
- page2_image_path = None
975
- clips = []
976
- audio_info_list = []
977
- total_duration = 0.0
978
-
979
- policy = playback_policy or {}
980
- match_audio = bool(policy.get("match_audio_length", True))
981
- fallback_seconds = policy.get("fallback_seconds_per_slide", 6.0)
982
- if fallback_seconds is None or fallback_seconds <= 0:
983
- fallback_seconds = 6.0
984
- padding_default = policy.get("padding_seconds", 0.6)
985
- if padding_default is None or padding_default < 0:
986
- padding_default = 0.6
987
-
988
- try:
989
- normalized_notes = normalize_notes_payload(notes_payload)
990
- notes_map = {entry["slide_index"]: entry for entry in normalized_notes}
991
-
992
- if progress_callback:
993
- progress_callback(0.05, desc="PDFダウンロード中...")
994
-
995
- pdf_path = download_pdf_from_url(sanitize_url(pdf_url))
996
-
997
- if progress_callback:
998
- progress_callback(0.1, desc="PDF→画像変換中...")
999
-
1000
- images = convert_pdf_to_images(pdf_path, dpi=150)
1001
- total_slides = len(images)
1002
-
1003
- if total_slides == 0:
1004
- raise Exception("PDFにページが含まれていません")
1005
-
1006
- note_entries: List[Dict] = []
1007
- text_map: Dict[int, str] = {}
1008
- tts_results: Dict[int, Optional[bytes]] = {}
1009
-
1010
- for idx in range(total_slides):
1011
- note_entry = notes_map.get(idx, {
1012
- "slide_index": idx,
1013
- "text": "",
1014
- "speaking_rate": 1.0,
1015
- "padding_seconds": None
1016
- })
1017
- note_entries.append(note_entry)
1018
- text = str(note_entry.get("text", "")).strip()
1019
- text_map[idx] = text
1020
-
1021
- total_audio_jobs = sum(1 for text in text_map.values() if text)
1022
-
1023
- if progress_callback:
1024
- progress_callback(0.1, desc="音声生成ジョブ準備中...")
1025
-
1026
- if total_audio_jobs > 0:
1027
- max_workers = min(MAX_EDUCATION_TTS_WORKERS, total_audio_jobs)
1028
- futures = {}
1029
- completed_jobs = 0
1030
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
1031
- for idx, text in text_map.items():
1032
- if not text:
1033
- tts_results[idx] = None
1034
- continue
1035
- futures[executor.submit(
1036
- generate_audio_with_gemini,
1037
- text,
1038
- gemini_token,
1039
- model="gemini-2.5-flash-preview-tts",
1040
- )] = idx
1041
-
1042
- for future in as_completed(futures):
1043
- idx = futures[future]
1044
- try:
1045
- wav_bytes = future.result()
1046
- except Exception as exc:
1047
- logger.error(f"Gemini TTS生成失敗 (slide={idx}): {exc}")
1048
- raise
1049
- tts_results[idx] = wav_bytes
1050
- completed_jobs += 1
1051
- if progress_callback:
1052
- progress = 0.1 + (completed_jobs / total_audio_jobs) * 0.4
1053
- progress_callback(
1054
- min(progress, 0.5),
1055
- desc=f"音声生成中 ({completed_jobs}/{total_audio_jobs})"
1056
- )
1057
- else:
1058
- if progress_callback:
1059
- progress_callback(0.5, desc="音声生成スキップ(テキストなし)")
1060
-
1061
- for idx, pil_image in enumerate(images):
1062
- note_entry = note_entries[idx]
1063
- text = text_map[idx]
1064
- speaking_rate = note_entry.get("speaking_rate", 1.25) or 1.0
1065
- if speaking_rate <= 0:
1066
- speaking_rate = 1.0
1067
- padding_seconds = note_entry.get("padding_seconds")
1068
- if padding_seconds is None or padding_seconds < 0:
1069
- padding_seconds = padding_default
1070
-
1071
- audio_duration = 0.0
1072
- slide_duration = fallback_seconds
1073
- audio_url = None
1074
- audio_path = None
1075
-
1076
- if text:
1077
- wav_bytes = tts_results.get(idx)
1078
- if wav_bytes is None:
1079
- raise RuntimeError(f"TTS音声が取得できませんでした (slide_index={idx})")
1080
-
1081
- if speaking_rate and abs(speaking_rate - 1.0) > 0.01:
1082
- wav_bytes = speed_up_audio(wav_bytes, speed_factor=speaking_rate)
1083
-
1084
- audio_duration = get_audio_duration(wav_bytes)
1085
- if match_audio:
1086
- slide_duration = max(audio_duration + padding_seconds, fallback_seconds)
1087
- else:
1088
- slide_duration = fallback_seconds
1089
-
1090
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_audio:
1091
- tmp_audio.write(wav_bytes)
1092
- audio_path = tmp_audio.name
1093
- audio_files.append(audio_path)
1094
-
1095
- audio_url = save_audio_to_hf(wav_bytes, prefix=f"education_slide_{idx:02d}")
1096
-
1097
- else:
1098
- slide_duration = fallback_seconds
1099
-
1100
- if progress_callback and total_slides:
1101
- progress = 0.5 + ((idx + 1) / total_slides) * 0.2
1102
- progress_callback(
1103
- min(progress, 0.7),
1104
- desc=f"動画クリップ生成中 ({idx + 1}/{total_slides})"
1105
- )
1106
-
1107
- img_array = convert_pil_to_array(pil_image, target_size=(1280, 720))
1108
- img_clip = ImageClip(img_array, duration=slide_duration)
1109
-
1110
- if audio_path:
1111
- audio_clip = AudioFileClip(audio_path)
1112
- img_clip = img_clip.set_audio(audio_clip)
1113
-
1114
- clips.append(img_clip)
1115
-
1116
- audio_info_list.append({
1117
- "slide_index": idx,
1118
- "slide_type": "notes",
1119
- "audio_url": audio_url,
1120
- "duration": audio_duration,
1121
- "text": text,
1122
- "speaking_rate": speaking_rate,
1123
- "playback_duration": slide_duration
1124
- })
1125
-
1126
- total_duration += slide_duration
1127
-
1128
- if not clips:
1129
- raise Exception("動画クリップが生成されませんでした(notesに有効なテキストがありません)")
1130
-
1131
- if progress_callback:
1132
- progress_callback(0.7, desc="動画をレンダリング中...")
1133
-
1134
- final_video = concatenate_videoclips(clips, method="compose")
1135
- tmp_video = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
1136
- video_path = tmp_video.name
1137
- tmp_video.close()
1138
-
1139
- final_video.write_videofile(
1140
- video_path,
1141
- fps=24,
1142
- codec="libx264",
1143
- audio_codec="aac",
1144
- temp_audiofile=os.path.join(tempfile.gettempdir(), f"temp_audio_{uuid.uuid4().hex}.m4a"),
1145
- remove_temp=True,
1146
- verbose=False,
1147
- logger=None
1148
- )
1149
- final_video.close()
1150
-
1151
- for clip in clips:
1152
- clip.close()
1153
-
1154
- if progress_callback:
1155
- progress_callback(0.85, desc="動画をアップロード中...")
1156
-
1157
- video_url = video_uploader.upload_video(video_path, prefix="education_video")
1158
-
1159
- page2_image_url = None
1160
- if total_slides >= 2:
1161
- with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_img:
1162
- page2_image_path = tmp_img.name
1163
- images[1].save(page2_image_path, format="JPEG", quality=90)
1164
- page2_image_url = video_uploader.upload_image(page2_image_path, prefix="education_page2")
1165
-
1166
- if progress_callback:
1167
- progress_callback(1.0, desc="完了!")
1168
-
1169
- return (video_url, page2_image_url, audio_info_list, total_slides, total_duration)
1170
-
1171
- finally:
1172
- for audio_file in audio_files:
1173
- if os.path.exists(audio_file):
1174
- try:
1175
- os.remove(audio_file)
1176
- except Exception as e:
1177
- logger.warning(f"音声ファイル削除エラー: {e}")
1178
-
1179
- if video_path and os.path.exists(video_path):
1180
- try:
1181
- os.remove(video_path)
1182
- except Exception as e:
1183
- logger.warning(f"動画ファイル削除エラー: {e}")
1184
-
1185
- if page2_image_path and os.path.exists(page2_image_path):
1186
- try:
1187
- os.remove(page2_image_path)
1188
- except Exception as e:
1189
- logger.warning(f"画像ファイル削除エラー: {e}")
1190
-
1191
- if pdf_path and os.path.exists(pdf_path):
1192
- try:
1193
- os.remove(pdf_path)
1194
- except Exception as e:
1195
- logger.warning(f"PDFファイル削除エラー: {e}")
1196
-
1197
  # ==============================
1198
  # コア機能実装
1199
  # ==============================
@@ -1616,60 +1269,6 @@ async def slidedata_to_video(request: SlideDataToVideoRequest):
1616
  detail=f"動画生成に失敗しました: {str(e)}"
1617
  )
1618
 
1619
-
1620
- @app.post(
1621
- "/api/education/notes-to-video",
1622
- response_model=AudioVideoResponse,
1623
- tags=["Video Generation", "Education"],
1624
- summary="賢杉賢太郎: notes配列から音声付き動画を生成",
1625
- description="賢杉賢太郎連携バージョン。GASが返すPDF URLとnotes配列を渡すと、音声付き動画を生成してアップロードします。"
1626
- )
1627
- async def education_notes_to_video(request: EducationVideoRequest):
1628
- """賢杉賢太郎連携バージョン: notesフィールドを活用した動画生成エンドポイント"""
1629
- gemini_token = os.environ.get("GEMINI_TOKEN")
1630
- if not gemini_token:
1631
- raise HTTPException(
1632
- status_code=500,
1633
- detail="GEMINI_TOKEN環境変数が設定されていません"
1634
- )
1635
-
1636
- try:
1637
- logger.info("賢杉賢太郎向けAPIリクエスト受信")
1638
- playback_policy = request.playback_policy.dict() if request.playback_policy else {}
1639
-
1640
- (
1641
- video_url,
1642
- page2_image_url,
1643
- audio_info_list,
1644
- total_slides,
1645
- total_duration
1646
- ) = create_video_with_notes(
1647
- pdf_url=request.pdf_url,
1648
- notes_payload=request.notes,
1649
- gemini_token=gemini_token,
1650
- playback_policy=playback_policy
1651
- )
1652
-
1653
- logger.info(f"賢杉賢太郎向け動画生成完了: {video_url}")
1654
-
1655
- return AudioVideoResponse(
1656
- status="success",
1657
- video_url=video_url,
1658
- page2_image_url=page2_image_url,
1659
- audio_urls=audio_info_list,
1660
- message="賢杉賢太郎用の音声付き動画の生成とアップロードに成功しました",
1661
- total_slides=total_slides,
1662
- video_duration=total_duration
1663
- )
1664
-
1665
- except HTTPException:
1666
- raise
1667
- except Exception as e:
1668
- logger.error(f"賢杉賢太郎向け動画生成エラー: {e}", exc_info=True)
1669
- raise HTTPException(
1670
- status_code=500,
1671
- detail=f"賢杉賢太郎向け動画生成に失敗しました: {str(e)}"
1672
- )
1673
  @app.get("/health")
1674
  async def health_check():
1675
  """ヘルスチェックエンドポイント"""
 
7
  from fastapi import FastAPI, HTTPException
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from pydantic import BaseModel, HttpUrl
10
+ from typing import Optional, Union
11
  import requests
12
  import tempfile
13
  import os
 
16
  from datetime import datetime
17
  import uuid
18
  from pathlib import Path
 
19
 
20
  # 画像・動画処理ライブラリ
21
  from pdf2image import convert_from_path
 
30
  logging.basicConfig(level=logging.INFO)
31
  logger = logging.getLogger(__name__)
32
 
 
 
 
 
 
33
  # ==============================
34
  # リクエスト/レスポンスモデル
35
  # ==============================
 
74
  total_slides: Optional[int] = None
75
  video_duration: Optional[float] = None
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  # ==============================
78
  # URL前処理ユーティリティ
79
  # ==============================
 
257
  return ""
258
 
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  def convert_pil_to_array(pil_image: Image.Image, target_size: tuple) -> np.ndarray:
261
  """
262
  PIL ImageをNumPy配列に変換し、指定サイズにリサイズ
 
284
  # V2.0: Gemini TTS音声生成
285
  # ==============================
286
 
287
+ def generate_audio_with_gemini(audio_text: str, gemini_token: str) -> bytes:
 
 
 
 
288
  """
289
  Gemini REST APIでテキストから音声を生成
290
 
291
  Args:
292
  audio_text: 読み上げるテキスト
293
  gemini_token: GEMINI_TOKEN環境変数
 
294
 
295
  Returns:
296
  WAVバイナリデータ(24kHz PCM16)
297
  """
298
  import base64
299
 
300
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro-preview-tts:generateContent?key={gemini_token}"
 
 
 
301
 
302
  headers = {
303
  "Content-Type": "application/json"
 
329
  }
330
  }
331
 
332
+ logger.info(f"Gemini TTS API呼び出し: {len(audio_text)}文字")
333
  logger.info(f"Payload: {payload}")
334
 
335
+ response = requests.post(url, json=payload, headers=headers, timeout=60)
336
 
337
  # エラーレスポンスの詳細をログ出力
338
  if response.status_code != 200:
 
847
  except Exception as e:
848
  logger.warning(f"動画ファイル削除エラー: {e}")
849
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
850
  # ==============================
851
  # コア機能実装
852
  # ==============================
 
1269
  detail=f"動画生成に失敗しました: {str(e)}"
1270
  )
1271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1272
  @app.get("/health")
1273
  async def health_check():
1274
  """ヘルスチェックエンドポイント"""
実装ステップ.md ADDED
@@ -0,0 +1,709 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PDF→動画変換API 実装ステップ
2
+
3
+ ## 📋 プロジェクト概要
4
+
5
+ ### 目的
6
+ PDFファイルをURLから取得し、各ページを画像に分解して5秒ずつ表示するスライドショー動画を生成し、Hugging Faceデータセットリポジトリに保存してURLを返すAPIを構築する。
7
+
8
+ ### 技術スタック
9
+ - **Gradio 4.19.2** - UI/SDK(Hugging Face Spacesのフレームワーク)
10
+ - **FastAPI** - RESTful APIエンドポイント
11
+ - **pdf2image** - PDF→画像変換(popplerベース)
12
+ - **OpenCV (cv2)** - 動画生成
13
+ - **huggingface_hub** - データセットへのファイルアップロード
14
+ - **requests** - PDF URLからのダウンロード
15
+
16
+ ### 処理フロー
17
+ ```
18
+ PDF URL → ダウンロード → PDF→画像変換 → 動画生成(5秒/ページ) → HF Dataset保存 → URL返却
19
+ ```
20
+
21
+ ---
22
+
23
+ ## 🗂️ ディレクトリ構造
24
+
25
+ ```
26
+ majin/v4_spinoff/HUG/
27
+ ├── 実装ステップ.md # 本ドキュメント
28
+ ├── app.py # メインアプリケーション
29
+ ├── requirements.txt # Python依存パッケージ
30
+ ├── packages.txt # システムパッケージ
31
+ ├── README.md # Hugging Face Space設定
32
+ ├── .env.example # 環境変数のサンプル
33
+ └── FASTAPI_GRADIO_sample/ # 参考資料(既存)
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 📝 実装ステップ
39
+
40
+ ### フェーズ1: 環境セットアップ
41
+
42
+ #### ステップ1.1: packages.txt作成
43
+ システムレベルの依存パッケージを定義します。
44
+
45
+ **ファイル: `packages.txt`**
46
+ ```
47
+ poppler-utils
48
+ ffmpeg
49
+ ```
50
+
51
+ **解説:**
52
+ - `poppler-utils`: pdf2imageがPDFをレンダリングするために必要
53
+ - `ffmpeg`: OpenCVの動画エンコーディングに必要
54
+
55
+ #### ステップ1.2: requirements.txt作成
56
+ Pythonパッケージの依存関係を定義します。
57
+
58
+ **ファイル: `requirements.txt`**
59
+ ```
60
+ gradio==4.19.2
61
+ fastapi
62
+ uvicorn[standard]
63
+ pdf2image
64
+ opencv-python-headless
65
+ Pillow
66
+ requests
67
+ huggingface_hub
68
+ python-dotenv
69
+ ```
70
+
71
+ **解説:**
72
+ - `opencv-python-headless`: GUI不要のOpenCV(Spaceに最適)
73
+ - `huggingface_hub`: Dataset APIアクセス用
74
+ - `python-dotenv`: 環境変数管理(開発時)
75
+
76
+ ---
77
+
78
+ ### フェーズ2: コア機能実装
79
+
80
+ #### ステップ2.1: PDFダウンロード機能
81
+
82
+ ```python
83
+ import requests
84
+ import tempfile
85
+ import os
86
+ from pathlib import Path
87
+
88
+ def download_pdf_from_url(pdf_url: str) -> str:
89
+ """
90
+ 指定されたURLからPDFをダウンロードして一時ファイルとして保存
91
+
92
+ Args:
93
+ pdf_url: PDFファイルのURL
94
+
95
+ Returns:
96
+ str: ダウンロードされたPDFファイルのパス
97
+
98
+ Raises:
99
+ Exception: ダウンロード失敗時
100
+ """
101
+ try:
102
+ logger.info(f"PDFダウンロード開始: {pdf_url}")
103
+
104
+ # HTTPリクエスト
105
+ response = requests.get(pdf_url, timeout=30, stream=True)
106
+ response.raise_for_status()
107
+
108
+ # Content-Typeの検証
109
+ content_type = response.headers.get('Content-Type', '')
110
+ if 'pdf' not in content_type.lower():
111
+ logger.warning(f"Content-Type が PDF ではありません: {content_type}")
112
+
113
+ # 一時ファイルに保存
114
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_file:
115
+ tmp_path = tmp_file.name
116
+ for chunk in response.iter_content(chunk_size=8192):
117
+ tmp_file.write(chunk)
118
+
119
+ logger.info(f"PDFダウンロード完了: {tmp_path}")
120
+ return tmp_path
121
+
122
+ except requests.exceptions.RequestException as e:
123
+ logger.error(f"PDFダウンロードエラー: {e}")
124
+ raise Exception(f"PDFのダウンロードに失敗しました: {e}")
125
+ ```
126
+
127
+ **ポイント:**
128
+ - `stream=True`で大きなファイルにも対応
129
+ - タイムアウト設定で無限待機を防止
130
+ - Content-Type検証でPDF以外のファイルを警告
131
+
132
+ #### ステップ2.2: PDF→画像変換機能
133
+
134
+ ```python
135
+ from pdf2image import convert_from_path
136
+ from PIL import Image
137
+ import logging
138
+
139
+ logger = logging.getLogger(__name__)
140
+
141
+ def convert_pdf_to_images(pdf_path: str, dpi: int = 150) -> list:
142
+ """
143
+ PDFファイルを画像リストに変換
144
+
145
+ Args:
146
+ pdf_path: PDFファイルのパス
147
+ dpi: 解像度(デフォルト150)
148
+
149
+ Returns:
150
+ list: PIL.Imageオブジェクトのリスト
151
+ """
152
+ try:
153
+ logger.info(f"PDF→画像変換開始: {pdf_path}, DPI={dpi}")
154
+
155
+ # PDFを画像に変換
156
+ images = convert_from_path(
157
+ pdf_path,
158
+ dpi=dpi,
159
+ fmt='jpeg', # JPEG形式で出力
160
+ thread_count=2 # 並列処理スレッド数
161
+ )
162
+
163
+ logger.info(f"PDF変換完了: {len(images)}ページ")
164
+ return images
165
+
166
+ except Exception as e:
167
+ logger.error(f"PDF変換エラー: {e}")
168
+ raise Exception(f"PDFの画像変換に失敗しました: {e}")
169
+ ```
170
+
171
+ **ポイント:**
172
+ - DPI調整で画質と処理速度のバランス調整
173
+ - JPEG形式で動画サイズを抑制
174
+ - thread_count でリソース使用を制御
175
+
176
+ #### ステップ2.3: 画像→動画生成機能
177
+
178
+ ```python
179
+ import cv2
180
+ import numpy as np
181
+ from PIL import Image
182
+ import tempfile
183
+
184
+ def create_video_from_images(
185
+ images: list,
186
+ duration_per_page: int = 5,
187
+ fps: int = 30
188
+ ) -> str:
189
+ """
190
+ 画像リストからスライドショー動画を生成
191
+
192
+ Args:
193
+ images: PIL.Imageオブジェクトのリスト
194
+ duration_per_page: 1ページあたりの表示秒数(デフォルト5秒)
195
+ fps: フレームレート(デフォルト30fps)
196
+
197
+ Returns:
198
+ str: 生成された動画ファイルのパス
199
+ """
200
+ try:
201
+ if not images:
202
+ raise ValueError("画像リストが空です")
203
+
204
+ logger.info(f"動画生成開始: {len(images)}ページ, {duration_per_page}秒/ページ, {fps}fps")
205
+
206
+ # 全画像を同じサイズにリサイズ(最初の画像のサイズに統一)
207
+ first_img = images[0]
208
+ width, height = first_img.size
209
+ logger.info(f"動画サイズ: {width}x{height}")
210
+
211
+ # 一時ファイルパス
212
+ tmp_video = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
213
+ video_path = tmp_video.name
214
+ tmp_video.close()
215
+
216
+ # 動画ライター初期化
217
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
218
+ video_writer = cv2.VideoWriter(
219
+ video_path,
220
+ fourcc,
221
+ fps,
222
+ (width, height)
223
+ )
224
+
225
+ # 各画像を指定秒数分のフレームとして追加
226
+ frames_per_page = duration_per_page * fps
227
+
228
+ for idx, img in enumerate(images):
229
+ logger.info(f"ページ {idx+1}/{len(images)} を処理中...")
230
+
231
+ # 画像をリサイズ(必要な場合)
232
+ if img.size != (width, height):
233
+ img = img.resize((width, height), Image.Resampling.LANCZOS)
234
+
235
+ # PIL Image → OpenCV形式に変換(RGB→BGR)
236
+ img_array = np.array(img)
237
+ if len(img_array.shape) == 3 and img_array.shape[2] == 3:
238
+ img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
239
+ else:
240
+ img_bgr = img_array
241
+
242
+ # 同じフレームを複数回書き込み(静止画として表示)
243
+ for _ in range(frames_per_page):
244
+ video_writer.write(img_bgr)
245
+
246
+ video_writer.release()
247
+ logger.info(f"動画生成完了: {video_path}")
248
+
249
+ return video_path
250
+
251
+ except Exception as e:
252
+ logger.error(f"動画生成エラー: {e}")
253
+ raise Exception(f"動画の生成に失敗しました: {e}")
254
+ ```
255
+
256
+ **ポイント:**
257
+ - 全画像を統一サイズにリサイズ(動画の要件)
258
+ - RGB→BGR変換(OpenCVの要件)
259
+ - フレーム数計算: `duration_per_page * fps`
260
+
261
+ #### ステップ2.4: Hugging Faceアップロード機能
262
+
263
+ ```python
264
+ from huggingface_hub import HfApi, login
265
+ import os
266
+ from datetime import datetime
267
+ import uuid
268
+
269
+ class VideoUploader:
270
+ """Hugging Face Datasetへの動画アップロード機能"""
271
+
272
+ def __init__(self):
273
+ self.repo_id = os.environ.get("HF_REPO_ID", "tomo2chin2/video-storage")
274
+ self.token = os.environ.get("HF_TOKEN")
275
+
276
+ if not self.token:
277
+ raise ValueError("HF_TOKEN 環境変数が設定されていません")
278
+
279
+ # ログイン
280
+ login(token=self.token)
281
+ self.api = HfApi()
282
+ logger.info(f"HuggingFace にログイン完了: {self.repo_id}")
283
+
284
+ def upload_video(self, video_path: str, prefix: str = "video") -> str:
285
+ """
286
+ 動画をHugging Faceデータセットにアップロード
287
+
288
+ Args:
289
+ video_path: アップロードする動画ファイルのパス
290
+ prefix: ファイル名のプレフィックス
291
+
292
+ Returns:
293
+ str: アップロードされた動画のURL
294
+ """
295
+ try:
296
+ # ユニークなファイル名を生成
297
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
298
+ unique_id = str(uuid.uuid4())[:8]
299
+ filename = f"{prefix}_{timestamp}_{unique_id}.mp4"
300
+ path_in_repo = f"videos/{filename}"
301
+
302
+ logger.info(f"動画アップロード開始: {path_in_repo}")
303
+
304
+ # アップロード実行
305
+ upload_info = self.api.upload_file(
306
+ path_or_fileobj=video_path,
307
+ path_in_repo=path_in_repo,
308
+ repo_id=self.repo_id,
309
+ repo_type="dataset"
310
+ )
311
+
312
+ # URLを構築
313
+ video_url = f"https://huggingface.co/datasets/{self.repo_id}/resolve/main/{path_in_repo}"
314
+
315
+ logger.info(f"動画アップロード完了: {video_url}")
316
+ return video_url
317
+
318
+ except Exception as e:
319
+ logger.error(f"動画アップロードエラー: {e}")
320
+ raise Exception(f"動画のアップロードに失敗しました: {e}")
321
+ ```
322
+
323
+ **ポイント:**
324
+ - タイムスタンプ+UUIDでファイル名の一意性を保証
325
+ - `repo_type="dataset"` でデータセットリポジトリを指定
326
+ - URL形式: `https://huggingface.co/datasets/{repo_id}/resolve/main/{path}`
327
+
328
+ ---
329
+
330
+ ### フェーズ3: FastAPI実装
331
+
332
+ #### ステップ3.1: リクエスト/レスポンスモデル定義
333
+
334
+ ```python
335
+ from pydantic import BaseModel, HttpUrl
336
+ from typing import Optional
337
+
338
+ class PdfToVideoRequest(BaseModel):
339
+ """PDF→動画変換リクエストモデル"""
340
+ pdf_url: HttpUrl
341
+ duration_per_page: int = 5 # デフォルト5秒
342
+ dpi: int = 150 # デフォルトDPI
343
+
344
+ class VideoResponse(BaseModel):
345
+ """動画生成レスポンスモデル"""
346
+ status: str
347
+ video_url: Optional[str] = None
348
+ message: str
349
+ total_pages: Optional[int] = None
350
+ video_duration: Optional[float] = None # 秒
351
+ ```
352
+
353
+ #### ステップ3.2: APIエンドポイント実装
354
+
355
+ ```python
356
+ from fastapi import FastAPI, HTTPException
357
+ from fastapi.middleware.cors import CORSMiddleware
358
+ import logging
359
+
360
+ # ロギング設定
361
+ logging.basicConfig(level=logging.INFO)
362
+ logger = logging.getLogger(__name__)
363
+
364
+ app = FastAPI(title="PDF to Video API")
365
+
366
+ # CORS設定
367
+ app.add_middleware(
368
+ CORSMiddleware,
369
+ allow_origins=["*"],
370
+ allow_credentials=True,
371
+ allow_methods=["*"],
372
+ allow_headers=["*"],
373
+ )
374
+
375
+ # グローバルなアップローダーインスタンス
376
+ video_uploader = VideoUploader()
377
+
378
+ @app.post(
379
+ "/api/pdf-to-video",
380
+ response_model=VideoResponse,
381
+ tags=["Video Generation"],
382
+ summary="PDFをスライドショー動画に変換",
383
+ description="指定されたURLからPDFをダウンロードし、各ページを画像化して動画を生成します。"
384
+ )
385
+ async def pdf_to_video(request: PdfToVideoRequest):
386
+ """PDF→動画変換APIエンドポイント"""
387
+ pdf_path = None
388
+ video_path = None
389
+
390
+ try:
391
+ logger.info(f"API リクエスト受信: {request.pdf_url}")
392
+
393
+ # 1. PDFダウンロード
394
+ pdf_path = download_pdf_from_url(str(request.pdf_url))
395
+
396
+ # 2. PDF→画像変換
397
+ images = convert_pdf_to_images(pdf_path, dpi=request.dpi)
398
+ total_pages = len(images)
399
+
400
+ # 3. 動画生成
401
+ video_path = create_video_from_images(
402
+ images,
403
+ duration_per_page=request.duration_per_page
404
+ )
405
+
406
+ # 4. Hugging Faceにアップロード
407
+ video_url = video_uploader.upload_video(video_path, prefix="pdf_video")
408
+
409
+ # 動画の総再生時間を計算
410
+ video_duration = total_pages * request.duration_per_page
411
+
412
+ logger.info(f"処理完了: {video_url}")
413
+
414
+ return VideoResponse(
415
+ status="success",
416
+ video_url=video_url,
417
+ message="動画の生成とアップロードに成功しました",
418
+ total_pages=total_pages,
419
+ video_duration=video_duration
420
+ )
421
+
422
+ except Exception as e:
423
+ logger.error(f"エラー発生: {e}", exc_info=True)
424
+ raise HTTPException(
425
+ status_code=500,
426
+ detail=f"動画生成に失敗しました: {str(e)}"
427
+ )
428
+
429
+ finally:
430
+ # 一時ファイルのクリーンアップ
431
+ if pdf_path and os.path.exists(pdf_path):
432
+ try:
433
+ os.remove(pdf_path)
434
+ logger.info(f"一時PDFファイル削除: {pdf_path}")
435
+ except Exception as e:
436
+ logger.warning(f"PDFファイル削除エラー: {e}")
437
+
438
+ if video_path and os.path.exists(video_path):
439
+ try:
440
+ os.remove(video_path)
441
+ logger.info(f"一時動画ファイル削除: {video_path}")
442
+ except Exception as e:
443
+ logger.warning(f"動画ファイル削除エラー: {e}")
444
+
445
+ @app.get("/health")
446
+ async def health_check():
447
+ """ヘルスチェックエンドポイント"""
448
+ return {"status": "healthy", "service": "PDF to Video API"}
449
+ ```
450
+
451
+ ---
452
+
453
+ ### フェーズ4: Gradio UI実装
454
+
455
+ ```python
456
+ import gradio as gr
457
+
458
+ def process_pdf_url(pdf_url, duration_per_page, dpi):
459
+ """Gradio UIからの処理関数"""
460
+ try:
461
+ if not pdf_url:
462
+ return None, "PDF URLを入力してください", None
463
+
464
+ # 内部的にAPI関数を呼び出し
465
+ request = PdfToVideoRequest(
466
+ pdf_url=pdf_url,
467
+ duration_per_page=duration_per_page,
468
+ dpi=dpi
469
+ )
470
+
471
+ # 同期的に処理(Gradioは非同期不要)
472
+ import asyncio
473
+ result = asyncio.run(pdf_to_video(request))
474
+
475
+ return (
476
+ result.video_url, # ビデオURL
477
+ f"✅ 成功: {result.total_pages}ページ、{result.video_duration}秒の動画を生成しました",
478
+ result.video_url # プレビュー用
479
+ )
480
+
481
+ except Exception as e:
482
+ logger.error(f"Gradio処理エラー: {e}")
483
+ return None, f"❌ エラー: {str(e)}", None
484
+
485
+ # Gradio UI定義
486
+ with gr.Blocks(title="PDF to Video Converter", theme=gr.themes.Soft()) as demo:
487
+ gr.Markdown("# 📄 PDF → 🎬 動画変換")
488
+ gr.Markdown("PDFのURLを指定すると、各ページをスライドショー動画に変換します。")
489
+
490
+ with gr.Row():
491
+ with gr.Column(scale=2):
492
+ pdf_url_input = gr.Textbox(
493
+ label="PDF URL",
494
+ placeholder="https://example.com/sample.pdf",
495
+ info="変換したいPDFファイルのURLを入力してください"
496
+ )
497
+
498
+ with gr.Row():
499
+ duration_slider = gr.Slider(
500
+ minimum=1,
501
+ maximum=10,
502
+ step=1,
503
+ value=5,
504
+ label="1ページあたりの表示秒数"
505
+ )
506
+
507
+ dpi_slider = gr.Slider(
508
+ minimum=72,
509
+ maximum=300,
510
+ step=1,
511
+ value=150,
512
+ label="画像解像度(DPI)",
513
+ info="高いほど高画質ですが処理時間が増加します"
514
+ )
515
+
516
+ convert_btn = gr.Button("🎬 動画生成", variant="primary", size="lg")
517
+
518
+ with gr.Column(scale=1):
519
+ status_output = gr.Textbox(
520
+ label="ステータス",
521
+ interactive=False
522
+ )
523
+ video_url_output = gr.Textbox(
524
+ label="動画URL",
525
+ interactive=False,
526
+ info="生成された動画のURL"
527
+ )
528
+
529
+ with gr.Row():
530
+ video_preview = gr.Video(
531
+ label="プレビュー",
532
+ interactive=False
533
+ )
534
+
535
+ # イベント設定
536
+ convert_btn.click(
537
+ fn=process_pdf_url,
538
+ inputs=[pdf_url_input, duration_slider, dpi_slider],
539
+ outputs=[video_url_output, status_output, video_preview]
540
+ )
541
+
542
+ # 使用例
543
+ gr.Markdown("""
544
+ ## 📖 使用方法
545
+ 1. PDFのURLを入力
546
+ 2. 1ページあたりの表示秒数を調整(デフォルト5秒)
547
+ 3. 解像度(DPI)を調整(デフォルト150)
548
+ 4. 「動画生成」ボタンをクリック
549
+ 5. 生成された動画URLをコピーして利用
550
+
551
+ ## ⚙️ 環境変数
552
+ - `HF_TOKEN`: Hugging Face認証トークン(必須)
553
+ - `HF_REPO_ID`: データセットリポジトリID(例: username/repo-name)
554
+
555
+ ## 🔗 APIエンドポイント
556
+ - `POST /api/pdf-to-video`: PDF→動画変換API
557
+ - `GET /health`: ヘルスチェック
558
+ """)
559
+
560
+ # FastAPIにGradioをマウント
561
+ app = gr.mount_gradio_app(app, demo, path="/")
562
+ ```
563
+
564
+ ---
565
+
566
+ ### フェーズ5: 設定ファイル作成
567
+
568
+ #### README.md (Hugging Face Space設定)
569
+
570
+ ```markdown
571
+ ---
572
+ title: PDF to Video Converter
573
+ emoji: 🎬
574
+ colorFrom: blue
575
+ colorTo: purple
576
+ sdk: gradio
577
+ sdk_version: 4.19.2
578
+ app_file: app.py
579
+ pinned: true
580
+ ---
581
+
582
+ # PDF to Video Converter
583
+
584
+ PDFファイルをスライドショー動画に変換するAPIです。
585
+
586
+ ## 機能
587
+ - PDF URLからのダウンロード
588
+ - 各ページを画像化
589
+ - 1ページ5秒のスライドショー動画生成
590
+ - Hugging Faceデータセットへの自動アップロード
591
+
592
+ ## API仕様
593
+ - `POST /api/pdf-to-video`
594
+
595
+ ## 環境変数
596
+ - `HF_TOKEN`: 必須
597
+ - `HF_REPO_ID`: データセットリポジトリID
598
+ ```
599
+
600
+ ---
601
+
602
+ ## 🚀 デプロイ手順
603
+
604
+ ### 1. Hugging Face Spaceの作成
605
+ 1. https://huggingface.co/new-space にアクセス
606
+ 2. Space名を入力(例: `pdf-to-video-converter`)
607
+ 3. SDKで「Gradio」を選択
608
+ 4. 「Create Space」をクリック
609
+
610
+ ### 2. ファイルのアップロード
611
+ ```bash
612
+ git clone https://huggingface.co/spaces/your-username/pdf-to-video-converter
613
+ cd pdf-to-video-converter
614
+
615
+ # ファイルをコピー
616
+ cp majin/v4_spinoff/HUG/app.py .
617
+ cp majin/v4_spinoff/HUG/requirements.txt .
618
+ cp majin/v4_spinoff/HUG/packages.txt .
619
+ cp majin/v4_spinoff/HUG/README.md .
620
+
621
+ # コミット&プッシュ
622
+ git add .
623
+ git commit -m "Initial implementation"
624
+ git push
625
+ ```
626
+
627
+ ### 3. 環境変数の設定
628
+ Space設定画面で以下を設定:
629
+ - `HF_TOKEN`: あなたのHugging Face Tokenを設定
630
+ - `HF_REPO_ID`: 動画保存先のデータセットリポジトリID
631
+
632
+ ### 4. 動作確認
633
+ - Space URLにアクセス
634
+ - サンプルPDF URLで動作テスト
635
+ - APIエンドポイント `/api/pdf-to-video` をテスト
636
+
637
+ ---
638
+
639
+ ## 🧪 テスト方法
640
+
641
+ ### curlでのAPIテスト
642
+
643
+ ```bash
644
+ curl -X POST "https://your-space-url.hf.space/api/pdf-to-video" \
645
+ -H "Content-Type: application/json" \
646
+ -d '{
647
+ "pdf_url": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
648
+ "duration_per_page": 5,
649
+ "dpi": 150
650
+ }'
651
+ ```
652
+
653
+ ### Pythonでのテスト
654
+
655
+ ```python
656
+ import requests
657
+
658
+ response = requests.post(
659
+ "https://your-space-url.hf.space/api/pdf-to-video",
660
+ json={
661
+ "pdf_url": "https://example.com/sample.pdf",
662
+ "duration_per_page": 5,
663
+ "dpi": 150
664
+ }
665
+ )
666
+
667
+ print(response.json())
668
+ # {"status": "success", "video_url": "https://...", ...}
669
+ ```
670
+
671
+ ---
672
+
673
+ ## ⚠️ 注意事項
674
+
675
+ 1. **PDFサイズ制限**: 大きなPDFは処理に時間がかかります(目安: 10ページ以下を推奨)
676
+ 2. **タイムアウト**: Hugging Face Spacesのタイムアウト制限に注意
677
+ 3. **ストレージ**: データセットリポジトリの容量制限を確認
678
+ 4. **セキュリティ**: PDF URLは信頼できるソースのみを使用
679
+
680
+ ---
681
+
682
+ ## 🔧 トラブルシューティング
683
+
684
+ ### pdf2imageエラー
685
+ - `packages.txt`に`poppler-utils`が含まれているか確認
686
+
687
+ ### 動画エンコードエラー
688
+ - `packages.txt`に`ffmpeg`が含まれているか確認
689
+ - OpenCVのバージョンを確認
690
+
691
+ ### アップロードエラー
692
+ - `HF_TOKEN`が正しく設定されているか確認
693
+ - データセットリポジトリが存在するか確認
694
+ - トークンに書き込み権限があるか確認
695
+
696
+ ---
697
+
698
+ ## 📚 参考資料
699
+
700
+ - [pdf2image Documentation](https://github.com/Belval/pdf2image)
701
+ - [OpenCV Video I/O](https://docs.opencv.org/4.x/dd/d43/tutorial_py_video_display.html)
702
+ - [Hugging Face Hub API](https://huggingface.co/docs/huggingface_hub/guides/upload)
703
+ - [Gradio Documentation](https://www.gradio.app/docs/)
704
+
705
+ ---
706
+
707
+ ## 📝 更新履歴
708
+
709
+ - 2025-10-07: 初版作成