tomo2chin2 commited on
Commit
565c2ee
·
verified ·
1 Parent(s): f42c406

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +486 -0
app.py CHANGED
@@ -48,6 +48,29 @@ class VideoResponse(BaseModel):
48
  total_pages: Optional[int] = None
49
  video_duration: Optional[float] = None # 秒
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  # ==============================
52
  # URL前処理ユーティリティ
53
  # ==============================
@@ -79,6 +102,420 @@ def sanitize_url(url: str) -> str:
79
  logger.info(f"URL sanitized: {url} → {cleaned_url}")
80
  return cleaned_url
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  # ==============================
83
  # コア機能実装
84
  # ==============================
@@ -447,6 +884,55 @@ async def pdf_to_video(request: PdfToVideoRequest):
447
  except Exception as e:
448
  logger.warning(f"画像ファイル削除エラー: {e}")
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  @app.get("/health")
451
  async def health_check():
452
  """ヘルスチェックエンドポイント"""
 
48
  total_pages: Optional[int] = None
49
  video_duration: Optional[float] = None # 秒
50
 
51
+ # V2.0: スライドデータ→音声付き動画変換モデル
52
+ class SlideDataToVideoRequest(BaseModel):
53
+ """スライドデータ→音声付き動画変換リクエスト"""
54
+ slide_data: list # スライドデータJSON配列
55
+
56
+ class AudioInfo(BaseModel):
57
+ """音声情報"""
58
+ slide_index: int
59
+ slide_type: str
60
+ audio_url: str
61
+ duration: float
62
+ text: str
63
+
64
+ class AudioVideoResponse(BaseModel):
65
+ """音声付き動画生成レスポンス"""
66
+ status: str
67
+ video_url: Optional[str] = None
68
+ page2_image_url: Optional[str] = None
69
+ audio_urls: list = [] # list[AudioInfo]として使用
70
+ message: str
71
+ total_slides: Optional[int] = None
72
+ video_duration: Optional[float] = None
73
+
74
  # ==============================
75
  # URL前処理ユーティリティ
76
  # ==============================
 
102
  logger.info(f"URL sanitized: {url} → {cleaned_url}")
103
  return cleaned_url
104
 
105
+ # ==============================
106
+ # V2.0: スライドデータ処理関数
107
+ # ==============================
108
+
109
+ def clean_mnemonic(text: str) -> str:
110
+ """
111
+ 語呂合わせから(数字)パターンを除去
112
+
113
+ Args:
114
+ text: 語呂合わせテキスト(例: "いい国つくろう鎌倉幕府(1192)")
115
+
116
+ Returns:
117
+ str: 数字を除去したテキスト(例: "いい国つくろう鎌倉幕府")
118
+ """
119
+ import re
120
+ cleaned = re.sub(r'(\d+)', '', text)
121
+ return cleaned
122
+
123
+
124
+ def determine_slide_type(slide: dict) -> str:
125
+ """
126
+ スライド種別を判定
127
+
128
+ Args:
129
+ slide: スライドデータ辞書
130
+
131
+ Returns:
132
+ str: "title" | "imageText_image_only" | "imageText_with_text" | "closing"
133
+ """
134
+ slide_type = slide.get("type", "")
135
+
136
+ if slide_type == "title":
137
+ return "title"
138
+ elif slide_type == "closing":
139
+ return "closing"
140
+ elif slide_type == "imageText":
141
+ points = slide.get("points", [])
142
+ if not points or len(points) == 0:
143
+ return "imageText_image_only"
144
+ else:
145
+ return "imageText_with_text"
146
+ else:
147
+ return "unknown"
148
+
149
+
150
+ def extract_audio_text(slide: dict) -> str:
151
+ """
152
+ スライドから音声テキストを抽出
153
+
154
+ Args:
155
+ slide: スライドデータ辞書
156
+
157
+ Returns:
158
+ str: 読み上げるテキスト
159
+ """
160
+ slide_type = determine_slide_type(slide)
161
+
162
+ if slide_type == "title":
163
+ # タイトルスライド: titleフィールドをそのまま
164
+ return slide.get("title", "")
165
+
166
+ elif slide_type == "imageText_image_only":
167
+ # 画像のみスライド: 年号 + 語呂合わせ(数字除去)× 2回
168
+ subhead = slide.get("subhead", "")
169
+
170
+ if ":" in subhead:
171
+ year = subhead.split(":")[0]
172
+ mnemonic = subhead.split(":")[1]
173
+ else:
174
+ year = ""
175
+ mnemonic = subhead
176
+
177
+ # (数字)パターンを除去
178
+ mnemonic_clean = clean_mnemonic(mnemonic)
179
+
180
+ # 2回繰り返し
181
+ audio_text = f"{year}年、{mnemonic_clean}。{year}年、{mnemonic_clean}。"
182
+ return audio_text
183
+
184
+ elif slide_type == "imageText_with_text":
185
+ # 画像+テキストスライド: pointsを結合(1回のみ)
186
+ points = slide.get("points", [])
187
+ # 各pointの末尾の句点を除去してから結合
188
+ cleaned_points = [p.rstrip("。") for p in points]
189
+ summary = "。".join(cleaned_points)
190
+ # 最後に句点を追加
191
+ if summary and not summary.endswith("。"):
192
+ summary += "。"
193
+ return summary
194
+
195
+ elif slide_type == "closing":
196
+ # クロージングスライド: notesフィールドまたはデフォルトメッセージ
197
+ return slide.get("notes", "本日の学習は以上です。復習を忘れずに。")
198
+
199
+ else:
200
+ return ""
201
+
202
+ # ==============================
203
+ # V2.0: Gemini TTS音声生成
204
+ # ==============================
205
+
206
+ def generate_audio_with_gemini(audio_text: str, gemini_token: str) -> bytes:
207
+ """
208
+ Gemini REST APIでテキストから音声を生成
209
+
210
+ Args:
211
+ audio_text: 読み上げるテキスト
212
+ gemini_token: GEMINI_TOKEN環境変数
213
+
214
+ Returns:
215
+ WAVバイナリデータ(24kHz PCM16)
216
+ """
217
+ import base64
218
+
219
+ # システムインストラクション
220
+ SYSTEM_INSTRUCTION = """あなたは歴史学習用の音声ナレーターです。
221
+ 以下のルールに従って読み上げてください:
222
+
223
+ 1. 正しい日本語の読みを意識する
224
+ 2. できるだけ早く、短時間で読み上げる
225
+ 3. 明瞭でハキハキとした発音
226
+ 4. 数字は「年号」として自然に読む
227
+ 5. 語呂合わせは楽しく、リズミカルに読む
228
+ 6. 歴史用語は正確な読み方で発音する
229
+ """
230
+
231
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key={gemini_token}"
232
+
233
+ headers = {
234
+ "Content-Type": "application/json"
235
+ }
236
+
237
+ payload = {
238
+ "contents": [
239
+ {
240
+ "parts": [
241
+ {
242
+ "text": f"{SYSTEM_INSTRUCTION}\n\n{audio_text}"
243
+ }
244
+ ]
245
+ }
246
+ ],
247
+ "generationConfig": {
248
+ "response_modalities": ["AUDIO"],
249
+ "speech_config": {
250
+ "voice_config": {
251
+ "prebuilt_voice_config": {
252
+ "voice_name": "Sharon"
253
+ }
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ logger.info(f"Gemini TTS API呼び出し: {len(audio_text)}文字")
260
+
261
+ response = requests.post(url, json=payload, headers=headers, timeout=60)
262
+ response.raise_for_status()
263
+
264
+ # レスポンスからaudioデータを取得(base64デコード)
265
+ response_data = response.json()
266
+ audio_data_b64 = response_data["candidates"][0]["content"]["parts"][0]["inlineData"]["data"]
267
+ pcm_bytes = base64.b64decode(audio_data_b64)
268
+
269
+ logger.info(f"音声データ取得完了: {len(pcm_bytes)} bytes (PCM)")
270
+
271
+ # PCM16をWAVファイルに変換
272
+ wav_bytes = convert_pcm_to_wav(pcm_bytes, sample_rate=24000, channels=1, sample_width=2)
273
+
274
+ logger.info(f"WAV変換完了: {len(wav_bytes)} bytes")
275
+
276
+ return wav_bytes
277
+
278
+
279
+ def convert_pcm_to_wav(pcm_bytes: bytes, sample_rate: int, channels: int, sample_width: int) -> bytes:
280
+ """
281
+ PCMバイナリをWAV形式に変換
282
+
283
+ Args:
284
+ pcm_bytes: PCMバイナリデータ
285
+ sample_rate: サンプルレート(Hz)
286
+ channels: チャンネル数(1=モノラル、2=ステレオ)
287
+ sample_width: サンプル幅(バイト、2=16bit)
288
+
289
+ Returns:
290
+ WAVバイナリデータ
291
+ """
292
+ import wave
293
+ import io
294
+
295
+ wav_buffer = io.BytesIO()
296
+
297
+ with wave.open(wav_buffer, 'wb') as wav_file:
298
+ wav_file.setnchannels(channels)
299
+ wav_file.setsampwidth(sample_width)
300
+ wav_file.setframerate(sample_rate)
301
+ wav_file.writeframes(pcm_bytes)
302
+
303
+ wav_buffer.seek(0)
304
+ return wav_buffer.read()
305
+
306
+
307
+ def get_audio_duration(wav_bytes: bytes) -> float:
308
+ """
309
+ WAVバイナリから音声の長さ(秒)を取得
310
+
311
+ Args:
312
+ wav_bytes: WAVバイナリデータ
313
+
314
+ Returns:
315
+ float: 音声の長さ(秒)
316
+ """
317
+ import wave
318
+ import io
319
+
320
+ wav_buffer = io.BytesIO(wav_bytes)
321
+
322
+ with wave.open(wav_buffer, 'rb') as wav_file:
323
+ frames = wav_file.getnframes()
324
+ rate = wav_file.getframerate()
325
+ duration = frames / float(rate)
326
+
327
+ return duration
328
+
329
+
330
+ def save_audio_to_hf(wav_bytes: bytes, prefix: str = "slide_audio") -> str:
331
+ """
332
+ 音声WAVファイルをHugging Faceデータセットにアップロード
333
+
334
+ Args:
335
+ wav_bytes: WAVバイナリデータ
336
+ prefix: ファイル名のプレフィックス
337
+
338
+ Returns:
339
+ str: アップロードされた音声ファイルのURL
340
+ """
341
+ # 一時ファイルに保存
342
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file:
343
+ tmp_file.write(wav_bytes)
344
+ tmp_path = tmp_file.name
345
+
346
+ try:
347
+ # HFアップロード
348
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
349
+ unique_id = str(uuid.uuid4())[:8]
350
+ filename = f"{prefix}_{timestamp}_{unique_id}.wav"
351
+ path_in_repo = f"audios/{filename}"
352
+
353
+ logger.info(f"音声アップロード開始: {path_in_repo}")
354
+
355
+ video_uploader.api.upload_file(
356
+ path_or_fileobj=tmp_path,
357
+ path_in_repo=path_in_repo,
358
+ repo_id=video_uploader.repo_id,
359
+ repo_type="dataset"
360
+ )
361
+
362
+ audio_url = f"https://huggingface.co/datasets/{video_uploader.repo_id}/resolve/main/{path_in_repo}"
363
+
364
+ logger.info(f"音声アップロード完了: {audio_url}")
365
+ return audio_url
366
+
367
+ finally:
368
+ # 一時ファイル削除
369
+ if os.path.exists(tmp_path):
370
+ os.remove(tmp_path)
371
+
372
+ # ==============================
373
+ # V2.0: 音声付き動画生成
374
+ # ==============================
375
+
376
+ def create_video_with_audio_from_slides(
377
+ slide_data: list,
378
+ gemini_token: str,
379
+ progress_callback=None
380
+ ) -> tuple:
381
+ """
382
+ スライドデータから音声付き動画を生成
383
+
384
+ Args:
385
+ slide_data: スライドデータJSON配列
386
+ gemini_token: GEMINI_TOKEN環境変数
387
+ progress_callback: 進捗コールバック関数(Gradio用)
388
+
389
+ Returns:
390
+ tuple: (video_url, page2_image_url, audio_info_list)
391
+ """
392
+ from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips
393
+
394
+ audio_files = [] # 一時ファイル管理
395
+ clips = []
396
+ audio_info_list = []
397
+ video_path = None
398
+
399
+ try:
400
+ total_slides = len(slide_data)
401
+
402
+ # 各スライドの音声生成と動画クリップ作成
403
+ for idx, slide in enumerate(slide_data):
404
+ if progress_callback:
405
+ progress_callback((idx / total_slides) * 0.6, desc=f"音声生成中 ({idx+1}/{total_slides})")
406
+
407
+ logger.info(f"スライド {idx+1}/{total_slides} 処理中...")
408
+
409
+ # 音声テキスト抽出
410
+ audio_text = extract_audio_text(slide)
411
+
412
+ if not audio_text:
413
+ logger.warning(f"スライド {idx+1}: 音声テキストが空です")
414
+ continue
415
+
416
+ # 音声生成
417
+ wav_bytes = generate_audio_with_gemini(audio_text, gemini_token)
418
+
419
+ # 音声長さ測定
420
+ audio_duration = get_audio_duration(wav_bytes)
421
+
422
+ # スライド再生時間計算(音声 + 2秒余白)
423
+ slide_duration = audio_duration + 2.0
424
+
425
+ # 音声を一時ファイルに保存
426
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_audio:
427
+ tmp_audio.write(wav_bytes)
428
+ audio_path = tmp_audio.name
429
+ audio_files.append(audio_path)
430
+
431
+ # HFアップロード
432
+ slide_type = determine_slide_type(slide)
433
+ audio_url = save_audio_to_hf(wav_bytes, prefix=f"slide_{idx:02d}_{slide_type}")
434
+
435
+ # 音声情報記録
436
+ audio_info_list.append({
437
+ "slide_index": idx,
438
+ "slide_type": slide_type,
439
+ "audio_url": audio_url,
440
+ "duration": audio_duration,
441
+ "text": audio_text
442
+ })
443
+
444
+ # 画像クリップ作成(仮の黒画像、実際はスライド画像を使用)
445
+ # TODO: スライド画像生成または取得
446
+ img_array = np.zeros((720, 1280, 3), dtype=np.uint8) # 仮の黒画像(720p)
447
+
448
+ # moviepyクリップ作成
449
+ img_clip = ImageClip(img_array, duration=slide_duration)
450
+ audio_clip = AudioFileClip(audio_path)
451
+
452
+ # 音声を動画に設定
453
+ video_clip = img_clip.set_audio(audio_clip)
454
+ clips.append(video_clip)
455
+
456
+ logger.info(f"スライド {idx+1}: 音声{audio_duration:.2f}秒, 再生時間{slide_duration:.2f}秒")
457
+
458
+ if not clips:
459
+ raise Exception("動画クリップが生成されませんでした")
460
+
461
+ if progress_callback:
462
+ progress_callback(0.7, desc="動画を結合中...")
463
+
464
+ # 全クリップを連結
465
+ final_video = concatenate_videoclips(clips, method="compose")
466
+
467
+ # 一時動画ファイルに出力
468
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp_video:
469
+ video_path = tmp_video.name
470
+
471
+ if progress_callback:
472
+ progress_callback(0.8, desc="動画をエンコード中...")
473
+
474
+ # 動画エンコード
475
+ final_video.write_videofile(
476
+ video_path,
477
+ fps=30,
478
+ codec='libx264',
479
+ audio_codec='aac',
480
+ logger=None # moviepyのログを抑制
481
+ )
482
+
483
+ # クリップをクローズ
484
+ final_video.close()
485
+ for clip in clips:
486
+ clip.close()
487
+
488
+ if progress_callback:
489
+ progress_callback(0.9, desc="動画をアップロード中...")
490
+
491
+ # HFアップロード
492
+ video_url = video_uploader.upload_video(video_path, prefix="slidedata_video")
493
+
494
+ # 2ページ目画像抽出・アップロード(TODO: 実装)
495
+ page2_image_url = None
496
+
497
+ if progress_callback:
498
+ progress_callback(1.0, desc="完了!")
499
+
500
+ logger.info(f"動画生成完了: {video_url}")
501
+
502
+ return (video_url, page2_image_url, audio_info_list)
503
+
504
+ finally:
505
+ # 一時ファイルクリーンアップ
506
+ for audio_file in audio_files:
507
+ if os.path.exists(audio_file):
508
+ try:
509
+ os.remove(audio_file)
510
+ except Exception as e:
511
+ logger.warning(f"音声ファイル削除エラー: {e}")
512
+
513
+ if video_path and os.path.exists(video_path):
514
+ try:
515
+ os.remove(video_path)
516
+ except Exception as e:
517
+ logger.warning(f"動画ファイル削除エラー: {e}")
518
+
519
  # ==============================
520
  # コア機能実装
521
  # ==============================
 
884
  except Exception as e:
885
  logger.warning(f"画像ファイル削除エラー: {e}")
886
 
887
+ @app.post(
888
+ "/api/slidedata-to-video",
889
+ response_model=AudioVideoResponse,
890
+ tags=["Video Generation"],
891
+ summary="スライドデータから音声付き動画を生成",
892
+ description="スライドデータJSONから音声を生成し、音声付き動画を作成します。"
893
+ )
894
+ async def slidedata_to_video(request: SlideDataToVideoRequest):
895
+ """スライドデータ→音声付き動画変換API��ンドポイント"""
896
+
897
+ # GEMINI_TOKEN取得
898
+ gemini_token = os.environ.get("GEMINI_TOKEN")
899
+ if not gemini_token:
900
+ raise HTTPException(
901
+ status_code=500,
902
+ detail="GEMINI_TOKEN環境変数が設定されていません"
903
+ )
904
+
905
+ try:
906
+ logger.info(f"API リクエスト受信: {len(request.slide_data)}スライド")
907
+
908
+ # 動画生成
909
+ video_url, page2_image_url, audio_info_list = create_video_with_audio_from_slides(
910
+ slide_data=request.slide_data,
911
+ gemini_token=gemini_token
912
+ )
913
+
914
+ # 総再生時間計算
915
+ total_duration = sum([info["duration"] + 2.0 for info in audio_info_list])
916
+
917
+ logger.info(f"処理完了: 動画={video_url}")
918
+
919
+ return AudioVideoResponse(
920
+ status="success",
921
+ video_url=video_url,
922
+ page2_image_url=page2_image_url,
923
+ audio_urls=audio_info_list,
924
+ message="音声付き動画の生成とアップロードに成功しました",
925
+ total_slides=len(request.slide_data),
926
+ video_duration=total_duration
927
+ )
928
+
929
+ except Exception as e:
930
+ logger.error(f"エラー発生: {e}", exc_info=True)
931
+ raise HTTPException(
932
+ status_code=500,
933
+ detail=f"動画生成に失敗しました: {str(e)}"
934
+ )
935
+
936
  @app.get("/health")
937
  async def health_check():
938
  """ヘルスチェックエンドポイント"""