2026/1/22更新

This commit is contained in:
lihongyang 2026-01-22 11:39:45 +08:00
parent 3c7e51eb7b
commit af4dfd3bb3
39 changed files with 1025 additions and 775 deletions

File diff suppressed because it is too large Load Diff

View File

@ -26,5 +26,23 @@
"戦略": "strategy",
"投資": "investment",
"挨拶": "greeting",
"疑問": "question"
"疑問": "question",
"Japanese family sitting together": "family gathering",
"Japanese couple calculating budget": "budgeting couple",
"Japanese man explaining feelings": "expressing feelings",
"Asian man nodding in agreement": "nodding in agreement",
"Japanese retired couple smiling": "smiling retirees",
"Japanese family visiting a new car": "car shopping",
"Japanese consultant pointing at document": "consultant pointing",
"Japanese advisor asking about family": "advisor inquiring",
"Asian man giving his birthday": "giving birthday",
"Japanese businessman greeting warmly": "warm greeting",
"Japanese student studying hard": "studying hard",
"Japanese calendar with events marked": "marked calendar",
"Japanese financial planner verifying age": "verifying age",
"Japanese advisor bowing politely": "polite bowing",
"Japanese man introduced himself": "self-introduction",
"Japanese financial consultant showing graphs": "showing graphs",
"Japanese financial advisor consulting client": "client consulting",
"Japanese man talking in interview": "interview talking"
}

View File

@ -61,9 +61,25 @@ class AudioGenerator:
text = segment["text"]
is_video_clip = segment.get("type") == "video_clip" or segment.get("visualType") == "video"
# Skip TTS for video clips (keep original audio)
if is_video_clip:
# Check if this segment should use existing audio (e.g. dialogue_clip)
current_audio_path = segment.get("audioPath", "")
is_manual_audio = "dialogue_clip" in current_audio_path
# Skip TTS for video clips or segments with manual audio
if is_video_clip or is_manual_audio:
if is_manual_audio:
print(f" ⏭️ Keeping manual audio for segment {segment_id}: {current_audio_path}")
else:
print(f" ⏭️ Keeping original audio for video segment {segment_id}")
# Still need to update duration if not present
if "duration" not in segment or segment["duration"] == 0:
try:
actual_duration = get_video_duration(str(current_audio_path))
segment["duration"] = actual_duration
except:
pass
updated_segments.append(segment)
continue

View File

@ -142,12 +142,14 @@ Example format:
results = {}
for jp_keyword, en_keyword in translations.items():
print(f" 🔍 Searching Pexels for '{en_keyword}'...")
# ENHANCED: Inject explicit Japanese context for every search to avoid Caucasian subjects
search_query = f"Japanese {en_keyword} people Asian"
print(f" 🔍 Searching Pexels for '{en_keyword}' (with strong Japanese/Asian priority)...")
# Search Pexels
# Search Pexels with "Japanese" added to the query for better relevance
headers = {"Authorization": self.api_key}
params = {
"query": en_keyword,
"query": search_query,
"per_page": max_per_keyword,
"orientation": "landscape"
}

View File

@ -1,6 +1,11 @@
import json
import sys
import os
from pathlib import Path
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Add project root to sys.path to allow importing backend modules
project_root = Path(__file__).parent.parent
@ -23,22 +28,33 @@ def rebuild_audio_from_props():
with open(props_path, "r", encoding="utf-8") as f:
props = json.load(f)
# Prepare script data for the generator
# RE-INDEX segments first
print("🔢 Re-indexing script segments and fixing paths...")
for i, segment in enumerate(props["script"]):
new_id = i + 1
segment["id"] = new_id
if segment.get("visualType") != "video":
segment["audioPath"] = f"public/segment_{new_id:03d}.mp3"
elif "audioPath" in segment:
# Determine if we should keep existing audio path
current_path = segment.get("audioPath", "")
is_original_clip = "dialogue_clip" in current_path
if segment.get("visualType") == "video":
if "audioPath" in segment:
del segment["audioPath"]
elif is_original_clip:
# Keep the dialogue_clip path as is
print(f" ⏭️ Segment {new_id}: Keeping original audio path {current_path}")
pass
else:
# Assign standard naming for AI generated segments
segment["audioPath"] = f"public/segment_{new_id:03d}.mp3"
script_data = {"segments": props["script"]}
# Generate new audio files (outputs to public/ folder)
print("🎙️ Generating new audio files...")
updated_script_data = generate_audio_for_script(script_data)
updated_script_data = generate_audio_for_script(script_data, voice="fable")
# voice options: "alloy", "echo", "fable", "onyx", "nova", "shimmer"
# Update the script in our props object
props["script"] = updated_script_data["segments"]

View File

@ -127,11 +127,15 @@ class ScriptGenerator:
2. インタビュー内容やPDFの数値を最大限に活用し、事実に基づいた具体的な解説を行ってください。
3. 各セグメントの视覚素材(`visualType`)は、以下から適切に選択してください:
- `ppt`: PPTスライドを表示する場合。`visualAssetIndex` を指定してください(基本はこちらを優先)。
- `broll`: 抽象的なイメージ映像を表示する場合。`brollKeyword` を必ず指定してください。
- `chart`: 資産推移や収支の動的なグラフを表示する場合。シミュレーションの結果を具体的に説明するシーンで使用します。
- `video`: 本人のインタビュー映像を表示する場合。本人の肉声や想いを伝えるシーンで使用します。`visualAssetIndex` にクリップの番号を指定してください。
- `dialogue`: 文字による対話形式(インタビューの再現)を表示する場合。動画の冒頭でインタビューのやり取りをテキストで再現する際に使用します。`speaker` 属性に話者名を指定してください。
- `dialogue`: 文字による対話形式(インタビューの再現)を表示する場合。動画の冒端でインタビューのやり取りをテキストで再現する際に使用します。`speaker` 属性に话者名を指定してください。
4. ナレーションの中に「スライド〇〇をご覧ください」や「インタビューでもおっしゃっていた通り...」といった言葉を入れ、視聴者の視線を誘導してください。
5. 真人インタビュー(`video` タイプ)を使用する場合、ナレーションテキスト(`text` 属性)はそのクリップで本人が話している内容と一致させてください。
6. `brollKeyword` についての指示:
- 単一の単語(例:「家族」)ではなく、**セグメントの文脈を反映した具体的な英語のフレーズ**(例:「Japanese family having dinner」、「Japanese businessman planning career」)を生成してください。
- 人物が登場する場合は、必ず**「Japanese」または「Asian」**というキーワードをフレーズに含めてください。
【顧客情報】
- お名前: {client_name}様
@ -170,7 +174,7 @@ class ScriptGenerator:
"targetYear": 2025,
"duration": 秒数,
"type": "narration" | "dialogue" | "chart_intro" | "chart_highlight" | "video_clip",
"visualType": "ppt" | "video" | "chart" | "dialogue",
"visualType": "ppt" | "video" | "chart" | "dialogue" | "broll",
"visualAssetIndex": 0,
"speaker": "話者名",
"brollKeyword": "キーワード"

84
backend/update_broll.py Normal file
View File

@ -0,0 +1,84 @@
import json
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
# Add project root to sys.path
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.append(str(PROJECT_ROOT))
from backend.media_fetcher import MediaFetcher
def update_broll_assets():
"""
Reads render_props.json, fetches B-Roll assets for segments that have
brollKeywords, and updates the assets.broll section.
Does NOT modify narration text.
"""
load_dotenv()
props_path = PROJECT_ROOT / "assets" / "1_processed" / "render_props.json"
if not props_path.exists():
print(f"❌ Error: {props_path} not found.")
return
print(f"📖 Reading {props_path}...")
with open(props_path, "r", encoding="utf-8") as f:
props = json.load(f)
# 1. Collect all keywords from script segments
keywords = set()
for segment in props.get("script", []):
kw = segment.get("brollKeyword")
if kw:
keywords.add(kw)
if not keywords:
print("ℹ️ No B-Roll keywords found in script. Skipping.")
return
print(f"🎬 Keywords found: {', '.join(keywords)}")
# 2. Fetch assets using MediaFetcher
try:
fetcher = MediaFetcher()
# Fetch 1 video per keyword
broll_results = fetcher.fetch_broll(list(keywords), max_per_keyword=1)
# 3. Update props["assets"]["broll"]
if "assets" not in props:
props["assets"] = {}
if "broll" not in props["assets"]:
props["assets"]["broll"] = {}
new_broll_map = {}
for kw, videos in broll_results.items():
if videos:
filename = Path(videos[0]["filepath"]).name
new_broll_map[kw] = filename
print(f" ✅ Mapped '{kw}' -> {filename}")
props["assets"]["broll"].update(new_broll_map)
# 4. Map brollPath in each segment for Remotion to use easily
# (Though Composition.tsx might already do this, we ensure consistency)
for segment in props.get("script", []):
kw = segment.get("brollKeyword")
if kw and kw in props["assets"]["broll"]:
filename = props["assets"]["broll"][kw]
segment["brollPath"] = f"broll/{filename}"
# 5. Save
with open(props_path, "w", encoding="utf-8") as f:
json.dump(props, f, ensure_ascii=False, indent=2)
print(f"✨ Successfully updated B-Roll assets in {props_path}")
except Exception as e:
print(f"❌ Failed to fetch B-Roll: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
update_broll_assets()

View File

@ -108,9 +108,9 @@ class VideoGeneratorPipeline:
print("\n🎙️ Step 3.5/6: Generating audio narration...")
script = self.generate_audio(script)
# Step 4: Fetch B-Roll assets (TEMPORARILY DISABLED)
print("\n🎬 Step 4/6: Fetching B-Roll assets (SKIPPED)...")
broll_assets = {} # self.fetch_broll(script, video_data)
# Step 4: Fetch B-Roll assets
print("\n🎬 Step 4/6: Fetching B-Roll assets...")
broll_assets = self.fetch_broll(script, video_data)
# Step 5: Build render props
print("\n📊 Step 5/6: Building render props...")
@ -225,7 +225,8 @@ class VideoGeneratorPipeline:
try:
# 1. Generate TTS audio for non-video segments
script_data = generate_audio_for_script(script_data, voice="alloy")
script_data = generate_audio_for_script(script_data, voice="nova")
# voice options: "alloy", "echo", "fable", "onyx", "nova", "shimmer"
# 2. Sync duration for video segments with actual clip duration
print(" 🎬 Syncing durations for interview video segments...")

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 KiB

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 KiB

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -200,6 +200,77 @@ const SyncedSubtitles: React.FC<{ text: string; durationInFrames: number }> = ({
);
};
// Helper component for B-Roll display in a dedicated space
const BRollWindow: React.FC<{ src: string; durationInFrames: number }> = ({ src, durationInFrames }) => {
const frame = useCurrentFrame();
const opacity = interpolate(
frame,
[0, 15, durationInFrames - 15, durationInFrames],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const scale = interpolate(
frame,
[0, durationInFrames],
[1.1, 1.0],
{ easing: Easing.out(Easing.quad) }
);
return (
<div style={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
opacity,
}}>
<div style={{
width: "90%",
height: "85%",
backgroundColor: "#000",
borderRadius: "32px",
overflow: "hidden",
boxShadow: "0 50px 100px rgba(0,0,0,0.4)",
border: "8px solid white",
position: "relative",
}}>
<OffthreadVideo
src={staticFile(src.replace(/^[\/\\]?public[\/\\]/, "").replace(/^[\/\\]/, ""))}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale})`,
}}
muted
/>
{/* Decorative element to give it a 'playing space' feel */}
<div style={{
position: "absolute",
bottom: 30,
left: 30,
background: "rgba(0,0,0,0.5)",
backdropFilter: "blur(4px)",
padding: "8px 16px",
borderRadius: "12px",
color: "white",
fontSize: "14px",
fontWeight: "bold",
display: "flex",
alignItems: "center",
gap: "8px"
}}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "#ff4757" }} />
SCENE FOOTAGE
</div>
</div>
</div>
);
};
export const MainVideo: React.FC<any> = (props) => {
const { charts, script, meta, assets } = props;
const frame = useCurrentFrame();
@ -233,14 +304,16 @@ export const MainVideo: React.FC<any> = (props) => {
else visualType = "ppt";
}
let brollPath = segment.brollPath || null;
let visualAssetIndex = -1;
let brollPath = null;
if (visualType === "broll" && segment.brollKeyword && brollMap[segment.brollKeyword]) {
// Ensure no leading slash or "public/" prefix, brollMap usually contains just the filename
// If brollPath is missing but keyword exists, try to resolve it
if (!brollPath && segment.brollKeyword && brollMap[segment.brollKeyword]) {
const filename = brollMap[segment.brollKeyword].split(/[\\/]/).pop();
brollPath = `broll/${filename}`;
} else if (typeof segment.visualAssetIndex === 'number') {
}
if (typeof segment.visualAssetIndex === 'number') {
visualAssetIndex = segment.visualAssetIndex;
} else if (typeof segment.slideIndex === 'number') {
visualAssetIndex = segment.slideIndex;
@ -276,15 +349,23 @@ export const MainVideo: React.FC<any> = (props) => {
return (
<AbsoluteFill style={{ backgroundColor: "#f8fafc" }}>
{/* 2.5 Dynamic Chart Layer: Rendered outside the Sequence loop to prevent animation reset */}
{/* Layer 1: Background Gradient (z-index: 1) */}
<AbsoluteFill style={{
background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)",
opacity: currentSegment?.visualType === "chart" ? 0 : 1,
zIndex: 1
}} />
{/* Layer 2: Dynamic Chart Layer (z-index: 3) - Continuous animation without reset */}
<AbsoluteFill style={{
paddingLeft: 380,
paddingRight: 100,
paddingRight: currentSegment?.brollPath ? 500 : 100,
paddingTop: 60,
paddingBottom: 160,
zIndex: 5, // Above background, below HUD/Avatar
zIndex: 3,
pointerEvents: "none",
opacity: currentSegment?.visualType === "chart" ? 1 : 0
opacity: currentSegment?.visualType === "chart" ? 1 : 0,
transition: "padding-right 0.5s ease-in-out"
}}>
<div style={{ width: "100%", height: "100%", backgroundColor: "white", borderRadius: 24, overflow: "hidden", boxShadow: "0 30px 60px rgba(0,0,0,0.15)", border: "1px solid rgba(0,0,0,0.05)" }}>
{charts?.cashFlow ? (
@ -304,24 +385,36 @@ export const MainVideo: React.FC<any> = (props) => {
{/* Render each script segment as a Sequence */}
{timeline.map((segment: any, sIdx: number) => {
const hasBroll = segment.brollPath && segment.visualType !== "video";
return (
<Sequence
key={`${segment.id}-${sIdx}`}
from={segment.startFrame}
durationInFrames={segment.durationInFrames}
>
{/* 1. Global Background: Light gradient (B-Roll disabled for now) */}
{/* Layer 3: Main Flex Grid (z-index: 5) - Ensures NO OVERLAP between content blocks */}
<AbsoluteFill style={{
background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)",
opacity: segment.visualType === "chart" ? 0 : 1 // Make transparent when chart is active
}} />
{/* 2. Main Area: PPT Slide or Video Clip (Shrunk and offset to the right) */}
<AbsoluteFill style={{
paddingLeft: segment.visualType === "dialogue" ? 100 : 380,
paddingRight: 100,
paddingLeft: 40, // Side margin
paddingRight: 40,
paddingTop: 60,
paddingBottom: 160
paddingBottom: 160,
display: "flex",
flexDirection: "row",
gap: 20,
zIndex: 5
}}>
{/* Left Column (320px) - Reserved for Key Insights */}
<div style={{ width: 320, flexShrink: 0 }} />
{/* Center Column (Flex 1) - PPT, Dialogue, or Video */}
<div style={{
flex: 1,
display: "flex",
justifyContent: "center",
alignItems: "center",
position: "relative",
opacity: segment.visualType === "chart" ? 0 : 1 // Hide when global chart is showing
}}>
{segment.visualType === "ppt" && (
<PPTWithZoom
@ -332,31 +425,6 @@ export const MainVideo: React.FC<any> = (props) => {
/>
)}
{/* In B-Roll mode, we show the video in the main area too but with focus */}
{segment.visualType === "broll" && segment.brollPath && (
<div style={{ width: "100%", height: "100%", backgroundColor: "black", borderRadius: 24, overflow: "hidden", boxShadow: "0 30px 60px rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)" }}>
<OffthreadVideo
src={staticFile(segment.brollPath.replace(/^[\/\\]?public[\/\\]/, "").replace(/^[\/\\]/, ""))}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
muted
/>
</div>
)}
{/* Chart Render - Removed from here to move to Global Layer for continuous animation */}
{/* Video Clip Render */}
{segment.visualType === "video" && segment.visualAssetIndex >= 0 && videoClips[segment.visualAssetIndex] && (
<div style={{ width: "100%", height: "100%", position: "absolute", zIndex: 10 }}>
<Video
src={staticFile((videoClips[segment.visualAssetIndex].outputPath || videoClips[segment.visualAssetIndex].filepath || "").replace(/^[\/\\]?public[\/\\]/, "").split(/[\\/]/).pop() || "")}
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: 16 }}
muted={false} // Always keep original audio for interview clips
/>
</div>
)}
{/* Dialogue Render */}
{segment.visualType === "dialogue" && (
<DialogueBox
text={segment.text}
@ -364,30 +432,59 @@ export const MainVideo: React.FC<any> = (props) => {
durationInFrames={segment.durationInFrames}
/>
)}
{segment.visualType === "video" && segment.visualAssetIndex >= 0 && videoClips[segment.visualAssetIndex] && (
<div style={{
width: "100%",
height: "100%",
backgroundColor: "black",
borderRadius: 24,
overflow: "hidden",
boxShadow: "0 30px 60px rgba(0,0,0,0.3)",
border: "1px solid rgba(255,255,255,0.1)"
}}>
<Video
src={staticFile((videoClips[segment.visualAssetIndex].outputPath || videoClips[segment.visualAssetIndex].filepath || "").replace(/^[\/\\]?public[\/\\]/, "").split(/[\\/]/).pop() || "")}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
muted={false}
/>
</div>
)}
</div>
{/* Right Column (400px) - B-Roll Footage */}
{hasBroll ? (
<div style={{ width: 400, flexShrink: 0, display: "flex", alignItems: "center" }}>
<BRollWindow
src={segment.brollPath}
durationInFrames={segment.durationInFrames}
/>
</div>
) : (
<div style={{ width: 60, flexShrink: 0 }} /> // Spacer
)}
</AbsoluteFill>
{/* Floating Insight (Prioritize manual 'insight' field, fallback to auto-extraction) */}
{/* Layer 4: Floating Insight Overlay (z-index: 10) */}
{(segment.insight || sIdx % 2 === 0) && (
<AbsoluteFill style={{ zIndex: 10, pointerEvents: "none" }}>
<FloatingInsight
text={segment.insight || segment.text.split("。")[0] + "。"}
delay={40}
durationInFrames={segment.durationInFrames}
/>
</AbsoluteFill>
)}
{/* Audio narration (if available) */}
{segment.audioPath && (
<Audio src={staticFile(segment.audioPath.split(/[\\/]/).pop() || "")} />
)}
{/* Subtitle overlay - Now using SyncedSubtitles (Hide for dialogue type as it has its own display) */}
{/* Layer 5: Subtitle Overlay (z-index: 15) */}
{segment.visualType !== "dialogue" && (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 80,
zIndex: 200,
zIndex: 15,
pointerEvents: "none"
}}
>
<SyncedSubtitles
@ -397,8 +494,14 @@ export const MainVideo: React.FC<any> = (props) => {
</AbsoluteFill>
)}
{/* Avatar (Virtual Human) - Hide during video clips and dialogue to avoid overlapping */}
{/* Audio narration (if available) */}
{segment.audioPath && (
<Audio src={staticFile(segment.audioPath.split(/[\\/]/).pop() || "")} />
)}
{/* Layer 6: Avatar Overlay (z-index: 100) */}
{segment.visualType !== "video" && segment.visualType !== "dialogue" && (
<AbsoluteFill style={{ zIndex: 100, pointerEvents: "none" }}>
<Avatar
isSpeaking={segment.type === "narration" || segment.type === "chart_intro"}
position="bottom-right"
@ -406,6 +509,7 @@ export const MainVideo: React.FC<any> = (props) => {
advisorName={advisorName}
advisorTitle={advisorTitle}
/>
</AbsoluteFill>
)}
</Sequence>
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB