281 lines
9.8 KiB
Python
281 lines
9.8 KiB
Python
"""
|
|
Dialogue Video Generator Module
|
|
|
|
This module extracts dialogue from video files, segments audio,
|
|
and generates render props for dialogue-style videos with speech bubbles.
|
|
|
|
Usage:
|
|
python backend/dialogue_video_generator.py --video path/to/video.mp4
|
|
python backend/dialogue_video_generator.py # Auto-detect from assets/0_input/
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List
|
|
|
|
from dotenv import load_dotenv
|
|
from backend.extractor import VideoExtractor
|
|
|
|
|
|
class DialogueVideoGenerator:
|
|
"""Generate dialogue video assets from interview recordings"""
|
|
|
|
def __init__(self, video_path: str = None):
|
|
"""
|
|
Initialize the generator
|
|
|
|
Args:
|
|
video_path: Path to video file. If None, auto-detect from assets/0_input/
|
|
"""
|
|
load_dotenv()
|
|
self.project_root = Path(__file__).parent.parent
|
|
self.video_path = self._resolve_video_path(video_path)
|
|
self.segments_output = self.project_root / "assets/1_processed/video_segments.json"
|
|
self.script_output = self.project_root / "assets/1_processed/dialogue_script.json"
|
|
self.props_output = self.project_root / "assets/1_processed/render_props_dialogue.json"
|
|
self.audio_output_dir = self.project_root / "public/dialogue"
|
|
|
|
def _resolve_video_path(self, video_path: str = None) -> Path:
|
|
"""Resolve video path from input or auto-detect"""
|
|
if video_path:
|
|
return Path(video_path)
|
|
|
|
# Auto-detect from assets/0_input/
|
|
input_dir = self.project_root / "assets/0_input"
|
|
video_extensions = ["*.mp4", "*.mov", "*.avi", "*.mkv"]
|
|
|
|
for ext in video_extensions:
|
|
video_files = list(input_dir.glob(ext))
|
|
if video_files:
|
|
print(f"✓ Auto-detected video: {video_files[0].name}")
|
|
return video_files[0]
|
|
|
|
raise FileNotFoundError("No video file found. Please provide --video path or place video in assets/0_input/")
|
|
|
|
def extract_segments(self) -> Dict[str, Any]:
|
|
"""
|
|
Extract audio transcription and timestamps from video
|
|
|
|
Returns:
|
|
Dictionary with transcription, segments, and metadata
|
|
"""
|
|
print(f"📹 Extracting segments from: {self.video_path.name}")
|
|
|
|
if not self.video_path.exists():
|
|
raise FileNotFoundError(f"Video file not found: {self.video_path}")
|
|
|
|
extractor = VideoExtractor()
|
|
result = extractor.extract(str(self.video_path))
|
|
|
|
# Save segments
|
|
with open(self.segments_output, "w", encoding="utf-8") as f:
|
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
|
|
|
print(f"✓ Saved {len(result['segments'])} segments to {self.segments_output.name}")
|
|
return result
|
|
|
|
def slice_audio(self, input_path: str, start: float, end: float, output_path: str):
|
|
"""Slice audio segment using ffmpeg"""
|
|
duration = end - start
|
|
cmd = [
|
|
"ffmpeg", "-y",
|
|
"-ss", str(start),
|
|
"-t", str(duration),
|
|
"-i", str(input_path),
|
|
"-c:a", "libmp3lame", "-b:a", "128k",
|
|
str(output_path)
|
|
]
|
|
subprocess.run(cmd, check=True, capture_output=True)
|
|
|
|
def identify_speaker(self, text: str, last_speaker: str) -> str:
|
|
"""
|
|
Identify speaker based on text content
|
|
|
|
Args:
|
|
text: Dialogue text
|
|
last_speaker: Previous segment's speaker (for alternation)
|
|
|
|
Returns:
|
|
Speaker label: "FP" or "家族代表様"
|
|
"""
|
|
# FP (Financial Planner) keywords
|
|
fp_keywords = [
|
|
"?", "お願いいたします", "でしょうか",
|
|
"教えていただけますか", "ありがとうございます",
|
|
"いらっしゃいますか", "そうですか"
|
|
]
|
|
|
|
is_fp = any(kw in text for kw in fp_keywords)
|
|
|
|
# Short confirmations alternate between speakers
|
|
if text in ["はい。", "そうですね。"]:
|
|
return "FP" if last_speaker == "家族代表様" else "家族代表様"
|
|
|
|
return "FP" if is_fp else "家族代表様"
|
|
|
|
def generate_audio_clips(self, segments: List[Dict]) -> List[Dict]:
|
|
"""
|
|
Generate individual audio clips for each dialogue segment
|
|
|
|
Args:
|
|
segments: List of segment dictionaries with text and timestamps
|
|
|
|
Returns:
|
|
List of processed segments with speaker labels and audio paths
|
|
"""
|
|
print(f"🎙️ Generating {len(segments)} audio clips...")
|
|
|
|
# Create output directory
|
|
self.audio_output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Find audio file
|
|
audio_path = self.video_path.parent / f"{self.video_path.stem}_audio.wav"
|
|
if not audio_path.exists():
|
|
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
|
|
|
processed_segments = []
|
|
last_speaker = "家族代表様"
|
|
|
|
for i, seg in enumerate(segments):
|
|
text = seg["text"]
|
|
speaker = self.identify_speaker(text, last_speaker)
|
|
last_speaker = speaker
|
|
|
|
# Generate audio clip
|
|
segment_filename = f"dialogue_seg_{i:03d}.mp3"
|
|
segment_path = self.audio_output_dir / segment_filename
|
|
|
|
print(f" [{i+1}/{len(segments)}] {text[:30]}...")
|
|
self.slice_audio(str(audio_path), seg["start"], seg["end"], str(segment_path))
|
|
|
|
processed_segments.append({
|
|
"id": i + 1,
|
|
"text": text,
|
|
"duration": seg["end"] - seg["start"],
|
|
"type": "dialogue",
|
|
"visualType": "dialogue",
|
|
"speaker": speaker,
|
|
"audioPath": f"public/dialogue/{segment_filename}",
|
|
"brollKeyword": "Japanese interview"
|
|
})
|
|
|
|
# Save dialogue script
|
|
with open(self.script_output, "w", encoding="utf-8") as f:
|
|
json.dump(processed_segments, f, ensure_ascii=False, indent=2)
|
|
|
|
print(f"✓ Generated {len(processed_segments)} dialogue segments")
|
|
return processed_segments
|
|
|
|
def create_render_props(self, dialogue_script: List[Dict]) -> Dict[str, Any]:
|
|
"""
|
|
Create render_props.json for dialogue video
|
|
|
|
Args:
|
|
dialogue_script: List of dialogue segments
|
|
|
|
Returns:
|
|
Complete render props dictionary
|
|
"""
|
|
print("📝 Creating render props for dialogue video...")
|
|
|
|
# Load original render_props for meta and charts
|
|
original_props_path = self.project_root / "assets/1_processed/render_props.json"
|
|
|
|
if original_props_path.exists():
|
|
with open(original_props_path, "r", encoding="utf-8") as f:
|
|
original_props = json.load(f)
|
|
else:
|
|
# Minimal fallback structure
|
|
original_props = {
|
|
"meta": {"title": "対話動画", "currencyUnit": "万円", "duration": 180},
|
|
"charts": {},
|
|
"assets": {"broll": {}, "videoClips": [], "slides": []}
|
|
}
|
|
|
|
# Construct new props
|
|
new_props = {
|
|
"meta": original_props.get("meta", {}),
|
|
"charts": original_props.get("charts", {}),
|
|
"script": dialogue_script,
|
|
"assets": original_props.get("assets", {})
|
|
}
|
|
|
|
# Update title
|
|
if "title" in new_props["meta"]:
|
|
new_props["meta"]["title"] = "【対話版】" + new_props["meta"]["title"]
|
|
|
|
# Save render props
|
|
with open(self.props_output, "w", encoding="utf-8") as f:
|
|
json.dump(new_props, f, ensure_ascii=False, indent=2)
|
|
|
|
print(f"✓ Saved render props to {self.props_output.name}")
|
|
return new_props
|
|
|
|
def run(self) -> str:
|
|
"""
|
|
Execute full dialogue video generation pipeline
|
|
|
|
Returns:
|
|
Path to generated render_props.json
|
|
"""
|
|
print("🚀 Starting Dialogue Video Generation Pipeline...")
|
|
print()
|
|
|
|
# Step 1: Extract segments
|
|
result = self.extract_segments()
|
|
segments = result["segments"]
|
|
print()
|
|
|
|
# Step 2: Generate audio clips
|
|
processed_segments = self.generate_audio_clips(segments)
|
|
print()
|
|
|
|
# Step 3: Create render props
|
|
self.create_render_props(processed_segments)
|
|
print()
|
|
|
|
print(f"✅ Pipeline complete! Use the following command to render:")
|
|
print(f" npx remotion render MainVideo assets/2_output/dialogue_video.mp4 \\")
|
|
print(f" --props=\"{self.props_output.relative_to(self.project_root)}\"")
|
|
|
|
return str(self.props_output)
|
|
|
|
|
|
def main():
|
|
"""CLI entry point"""
|
|
parser = argparse.ArgumentParser(
|
|
description="Dialogue Video Generator - Extract and render interview dialogues",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
python backend/dialogue_video_generator.py
|
|
python backend/dialogue_video_generator.py --video assets/0_input/interview.mp4
|
|
"""
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--video",
|
|
type=str,
|
|
help="Path to video file (auto-detect from assets/0_input/ if not provided)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
generator = DialogueVideoGenerator(video_path=args.video)
|
|
generator.run()
|
|
except Exception as e:
|
|
print(f"\n❌ Generation failed: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
exit(main())
|