2026/1/22更新
|
|
@ -26,5 +26,23 @@
|
||||||
"戦略": "strategy",
|
"戦略": "strategy",
|
||||||
"投資": "investment",
|
"投資": "investment",
|
||||||
"挨拶": "greeting",
|
"挨拶": "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"
|
||||||
}
|
}
|
||||||
|
|
@ -61,9 +61,25 @@ class AudioGenerator:
|
||||||
text = segment["text"]
|
text = segment["text"]
|
||||||
is_video_clip = segment.get("type") == "video_clip" or segment.get("visualType") == "video"
|
is_video_clip = segment.get("type") == "video_clip" or segment.get("visualType") == "video"
|
||||||
|
|
||||||
# Skip TTS for video clips (keep original audio)
|
# Check if this segment should use existing audio (e.g. dialogue_clip)
|
||||||
if is_video_clip:
|
current_audio_path = segment.get("audioPath", "")
|
||||||
print(f" ⏭️ Keeping original audio for video segment {segment_id}")
|
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)
|
updated_segments.append(segment)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,12 +142,14 @@ Example format:
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
for jp_keyword, en_keyword in translations.items():
|
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}
|
headers = {"Authorization": self.api_key}
|
||||||
params = {
|
params = {
|
||||||
"query": en_keyword,
|
"query": search_query,
|
||||||
"per_page": max_per_keyword,
|
"per_page": max_per_keyword,
|
||||||
"orientation": "landscape"
|
"orientation": "landscape"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
from pathlib import Path
|
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
|
# Add project root to sys.path to allow importing backend modules
|
||||||
project_root = Path(__file__).parent.parent
|
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:
|
with open(props_path, "r", encoding="utf-8") as f:
|
||||||
props = json.load(f)
|
props = json.load(f)
|
||||||
|
|
||||||
# Prepare script data for the generator
|
|
||||||
# RE-INDEX segments first
|
# RE-INDEX segments first
|
||||||
print("🔢 Re-indexing script segments and fixing paths...")
|
print("🔢 Re-indexing script segments and fixing paths...")
|
||||||
for i, segment in enumerate(props["script"]):
|
for i, segment in enumerate(props["script"]):
|
||||||
new_id = i + 1
|
new_id = i + 1
|
||||||
segment["id"] = new_id
|
segment["id"] = new_id
|
||||||
if segment.get("visualType") != "video":
|
|
||||||
|
# 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"
|
segment["audioPath"] = f"public/segment_{new_id:03d}.mp3"
|
||||||
elif "audioPath" in segment:
|
|
||||||
del segment["audioPath"]
|
|
||||||
|
|
||||||
script_data = {"segments": props["script"]}
|
script_data = {"segments": props["script"]}
|
||||||
|
|
||||||
# Generate new audio files (outputs to public/ folder)
|
# Generate new audio files (outputs to public/ folder)
|
||||||
print("🎙️ Generating new audio files...")
|
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
|
# Update the script in our props object
|
||||||
props["script"] = updated_script_data["segments"]
|
props["script"] = updated_script_data["segments"]
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,15 @@ class ScriptGenerator:
|
||||||
2. インタビュー内容やPDFの数値を最大限に活用し、事実に基づいた具体的な解説を行ってください。
|
2. インタビュー内容やPDFの数値を最大限に活用し、事実に基づいた具体的な解説を行ってください。
|
||||||
3. 各セグメントの视覚素材(`visualType`)は、以下から適切に選択してください:
|
3. 各セグメントの视覚素材(`visualType`)は、以下から適切に選択してください:
|
||||||
- `ppt`: PPTスライドを表示する場合。`visualAssetIndex` を指定してください(基本はこちらを優先)。
|
- `ppt`: PPTスライドを表示する場合。`visualAssetIndex` を指定してください(基本はこちらを優先)。
|
||||||
|
- `broll`: 抽象的なイメージ映像を表示する場合。`brollKeyword` を必ず指定してください。
|
||||||
- `chart`: 資産推移や収支の動的なグラフを表示する場合。シミュレーションの結果を具体的に説明するシーンで使用します。
|
- `chart`: 資産推移や収支の動的なグラフを表示する場合。シミュレーションの結果を具体的に説明するシーンで使用します。
|
||||||
- `video`: 本人のインタビュー映像を表示する場合。本人の肉声や想いを伝えるシーンで使用します。`visualAssetIndex` にクリップの番号を指定してください。
|
- `video`: 本人のインタビュー映像を表示する場合。本人の肉声や想いを伝えるシーンで使用します。`visualAssetIndex` にクリップの番号を指定してください。
|
||||||
- `dialogue`: 文字による対話形式(インタビューの再現)を表示する場合。動画の冒頭でインタビューのやり取りをテキストで再現する際に使用します。`speaker` 属性に話者名を指定してください。
|
- `dialogue`: 文字による対話形式(インタビューの再現)を表示する場合。動画の冒端でインタビューのやり取りをテキストで再現する際に使用します。`speaker` 属性に话者名を指定してください。
|
||||||
4. ナレーションの中に「スライド〇〇をご覧ください」や「インタビューでもおっしゃっていた通り...」といった言葉を入れ、視聴者の視線を誘導してください。
|
4. ナレーションの中に「スライド〇〇をご覧ください」や「インタビューでもおっしゃっていた通り...」といった言葉を入れ、視聴者の視線を誘導してください。
|
||||||
5. 真人インタビュー(`video` タイプ)を使用する場合、ナレーションテキスト(`text` 属性)はそのクリップで本人が話している内容と一致させてください。
|
5. 真人インタビュー(`video` タイプ)を使用する場合、ナレーションテキスト(`text` 属性)はそのクリップで本人が話している内容と一致させてください。
|
||||||
|
6. `brollKeyword` についての指示:
|
||||||
|
- 単一の単語(例:「家族」)ではなく、**セグメントの文脈を反映した具体的な英語のフレーズ**(例:「Japanese family having dinner」、「Japanese businessman planning career」)を生成してください。
|
||||||
|
- 人物が登場する場合は、必ず**「Japanese」または「Asian」**というキーワードをフレーズに含めてください。
|
||||||
|
|
||||||
【顧客情報】
|
【顧客情報】
|
||||||
- お名前: {client_name}様
|
- お名前: {client_name}様
|
||||||
|
|
@ -170,7 +174,7 @@ class ScriptGenerator:
|
||||||
"targetYear": 2025,
|
"targetYear": 2025,
|
||||||
"duration": 秒数,
|
"duration": 秒数,
|
||||||
"type": "narration" | "dialogue" | "chart_intro" | "chart_highlight" | "video_clip",
|
"type": "narration" | "dialogue" | "chart_intro" | "chart_highlight" | "video_clip",
|
||||||
"visualType": "ppt" | "video" | "chart" | "dialogue",
|
"visualType": "ppt" | "video" | "chart" | "dialogue" | "broll",
|
||||||
"visualAssetIndex": 0,
|
"visualAssetIndex": 0,
|
||||||
"speaker": "話者名",
|
"speaker": "話者名",
|
||||||
"brollKeyword": "キーワード"
|
"brollKeyword": "キーワード"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
9
main.py
|
|
@ -108,9 +108,9 @@ class VideoGeneratorPipeline:
|
||||||
print("\n🎙️ Step 3.5/6: Generating audio narration...")
|
print("\n🎙️ Step 3.5/6: Generating audio narration...")
|
||||||
script = self.generate_audio(script)
|
script = self.generate_audio(script)
|
||||||
|
|
||||||
# Step 4: Fetch B-Roll assets (TEMPORARILY DISABLED)
|
# Step 4: Fetch B-Roll assets
|
||||||
print("\n🎬 Step 4/6: Fetching B-Roll assets (SKIPPED)...")
|
print("\n🎬 Step 4/6: Fetching B-Roll assets...")
|
||||||
broll_assets = {} # self.fetch_broll(script, video_data)
|
broll_assets = self.fetch_broll(script, video_data)
|
||||||
|
|
||||||
# Step 5: Build render props
|
# Step 5: Build render props
|
||||||
print("\n📊 Step 5/6: Building render props...")
|
print("\n📊 Step 5/6: Building render props...")
|
||||||
|
|
@ -225,7 +225,8 @@ class VideoGeneratorPipeline:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Generate TTS audio for non-video segments
|
# 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
|
# 2. Sync duration for video segments with actual clip duration
|
||||||
print(" 🎬 Syncing durations for interview video segments...")
|
print(" 🎬 Syncing durations for interview video segments...")
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 624 KiB After Width: | Height: | Size: 601 KiB |
|
Before Width: | Height: | Size: 542 KiB After Width: | Height: | Size: 521 KiB |
|
Before Width: | Height: | Size: 588 KiB After Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 659 KiB After Width: | Height: | Size: 628 KiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 1.7 MiB |
|
|
@ -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) => {
|
export const MainVideo: React.FC<any> = (props) => {
|
||||||
const { charts, script, meta, assets } = props;
|
const { charts, script, meta, assets } = props;
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
|
|
@ -233,14 +304,16 @@ export const MainVideo: React.FC<any> = (props) => {
|
||||||
else visualType = "ppt";
|
else visualType = "ppt";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let brollPath = segment.brollPath || null;
|
||||||
let visualAssetIndex = -1;
|
let visualAssetIndex = -1;
|
||||||
let brollPath = null;
|
|
||||||
|
|
||||||
if (visualType === "broll" && segment.brollKeyword && brollMap[segment.brollKeyword]) {
|
// If brollPath is missing but keyword exists, try to resolve it
|
||||||
// Ensure no leading slash or "public/" prefix, brollMap usually contains just the filename
|
if (!brollPath && segment.brollKeyword && brollMap[segment.brollKeyword]) {
|
||||||
const filename = brollMap[segment.brollKeyword].split(/[\\/]/).pop();
|
const filename = brollMap[segment.brollKeyword].split(/[\\/]/).pop();
|
||||||
brollPath = `broll/${filename}`;
|
brollPath = `broll/${filename}`;
|
||||||
} else if (typeof segment.visualAssetIndex === 'number') {
|
}
|
||||||
|
|
||||||
|
if (typeof segment.visualAssetIndex === 'number') {
|
||||||
visualAssetIndex = segment.visualAssetIndex;
|
visualAssetIndex = segment.visualAssetIndex;
|
||||||
} else if (typeof segment.slideIndex === 'number') {
|
} else if (typeof segment.slideIndex === 'number') {
|
||||||
visualAssetIndex = segment.slideIndex;
|
visualAssetIndex = segment.slideIndex;
|
||||||
|
|
@ -276,15 +349,23 @@ export const MainVideo: React.FC<any> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: "#f8fafc" }}>
|
<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={{
|
<AbsoluteFill style={{
|
||||||
paddingLeft: 380,
|
paddingLeft: 380,
|
||||||
paddingRight: 100,
|
paddingRight: currentSegment?.brollPath ? 500 : 100,
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingBottom: 160,
|
paddingBottom: 160,
|
||||||
zIndex: 5, // Above background, below HUD/Avatar
|
zIndex: 3,
|
||||||
pointerEvents: "none",
|
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)" }}>
|
<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 ? (
|
{charts?.cashFlow ? (
|
||||||
|
|
@ -304,90 +385,106 @@ export const MainVideo: React.FC<any> = (props) => {
|
||||||
|
|
||||||
{/* Render each script segment as a Sequence */}
|
{/* Render each script segment as a Sequence */}
|
||||||
{timeline.map((segment: any, sIdx: number) => {
|
{timeline.map((segment: any, sIdx: number) => {
|
||||||
|
const hasBroll = segment.brollPath && segment.visualType !== "video";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sequence
|
<Sequence
|
||||||
key={`${segment.id}-${sIdx}`}
|
key={`${segment.id}-${sIdx}`}
|
||||||
from={segment.startFrame}
|
from={segment.startFrame}
|
||||||
durationInFrames={segment.durationInFrames}
|
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={{
|
<AbsoluteFill style={{
|
||||||
background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)",
|
paddingLeft: 40, // Side margin
|
||||||
opacity: segment.visualType === "chart" ? 0 : 1 // Make transparent when chart is active
|
paddingRight: 40,
|
||||||
}} />
|
|
||||||
|
|
||||||
{/* 2. Main Area: PPT Slide or Video Clip (Shrunk and offset to the right) */}
|
|
||||||
<AbsoluteFill style={{
|
|
||||||
paddingLeft: segment.visualType === "dialogue" ? 100 : 380,
|
|
||||||
paddingRight: 100,
|
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingBottom: 160
|
paddingBottom: 160,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 20,
|
||||||
|
zIndex: 5
|
||||||
}}>
|
}}>
|
||||||
{segment.visualType === "ppt" && (
|
{/* Left Column (320px) - Reserved for Key Insights */}
|
||||||
<PPTWithZoom
|
<div style={{ width: 320, flexShrink: 0 }} />
|
||||||
src={segment.visualAssetIndex >= 0 && slides[segment.visualAssetIndex]
|
|
||||||
? staticFile(slides[segment.visualAssetIndex].image_path.replace(/^[\/\\]?public[\/\\]/, "").replace(/^[\/\\]/, "").split(/[\\/]/).pop() || "")
|
|
||||||
: null}
|
|
||||||
durationInFrames={segment.durationInFrames}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* In B-Roll mode, we show the video in the main area too but with focus */}
|
{/* Center Column (Flex 1) - PPT, Dialogue, or Video */}
|
||||||
{segment.visualType === "broll" && segment.brollPath && (
|
<div style={{
|
||||||
<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)" }}>
|
flex: 1,
|
||||||
<OffthreadVideo
|
display: "flex",
|
||||||
src={staticFile(segment.brollPath.replace(/^[\/\\]?public[\/\\]/, "").replace(/^[\/\\]/, ""))}
|
justifyContent: "center",
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
alignItems: "center",
|
||||||
muted
|
position: "relative",
|
||||||
|
opacity: segment.visualType === "chart" ? 0 : 1 // Hide when global chart is showing
|
||||||
|
}}>
|
||||||
|
{segment.visualType === "ppt" && (
|
||||||
|
<PPTWithZoom
|
||||||
|
src={segment.visualAssetIndex >= 0 && slides[segment.visualAssetIndex]
|
||||||
|
? staticFile(slides[segment.visualAssetIndex].image_path.replace(/^[\/\\]?public[\/\\]/, "").replace(/^[\/\\]/, "").split(/[\\/]/).pop() || "")
|
||||||
|
: null}
|
||||||
|
durationInFrames={segment.durationInFrames}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{segment.visualType === "dialogue" && (
|
||||||
|
<DialogueBox
|
||||||
|
text={segment.text}
|
||||||
|
speaker={segment.speaker || "お客様"}
|
||||||
|
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>
|
||||||
)}
|
) : (
|
||||||
|
<div style={{ width: 60, flexShrink: 0 }} /> // Spacer
|
||||||
{/* 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}
|
|
||||||
speaker={segment.speaker || "お客様"}
|
|
||||||
durationInFrames={segment.durationInFrames}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
|
|
||||||
{/* Floating Insight (Prioritize manual 'insight' field, fallback to auto-extraction) */}
|
{/* Layer 4: Floating Insight Overlay (z-index: 10) */}
|
||||||
{(segment.insight || sIdx % 2 === 0) && (
|
{(segment.insight || sIdx % 2 === 0) && (
|
||||||
<FloatingInsight
|
<AbsoluteFill style={{ zIndex: 10, pointerEvents: "none" }}>
|
||||||
text={segment.insight || segment.text.split("。")[0] + "。"}
|
<FloatingInsight
|
||||||
delay={40}
|
text={segment.insight || segment.text.split("。")[0] + "。"}
|
||||||
durationInFrames={segment.durationInFrames}
|
delay={40}
|
||||||
/>
|
durationInFrames={segment.durationInFrames}
|
||||||
|
/>
|
||||||
|
</AbsoluteFill>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Audio narration (if available) */}
|
{/* Layer 5: Subtitle Overlay (z-index: 15) */}
|
||||||
{segment.audioPath && (
|
|
||||||
<Audio src={staticFile(segment.audioPath.split(/[\\/]/).pop() || "")} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtitle overlay - Now using SyncedSubtitles (Hide for dialogue type as it has its own display) */}
|
|
||||||
{segment.visualType !== "dialogue" && (
|
{segment.visualType !== "dialogue" && (
|
||||||
<AbsoluteFill
|
<AbsoluteFill
|
||||||
style={{
|
style={{
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingBottom: 80,
|
paddingBottom: 80,
|
||||||
zIndex: 200,
|
zIndex: 15,
|
||||||
|
pointerEvents: "none"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SyncedSubtitles
|
<SyncedSubtitles
|
||||||
|
|
@ -397,15 +494,22 @@ export const MainVideo: React.FC<any> = (props) => {
|
||||||
</AbsoluteFill>
|
</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" && (
|
{segment.visualType !== "video" && segment.visualType !== "dialogue" && (
|
||||||
<Avatar
|
<AbsoluteFill style={{ zIndex: 100, pointerEvents: "none" }}>
|
||||||
isSpeaking={segment.type === "narration" || segment.type === "chart_intro"}
|
<Avatar
|
||||||
position="bottom-right"
|
isSpeaking={segment.type === "narration" || segment.type === "chart_intro"}
|
||||||
size={200}
|
position="bottom-right"
|
||||||
advisorName={advisorName}
|
size={200}
|
||||||
advisorTitle={advisorTitle}
|
advisorName={advisorName}
|
||||||
/>
|
advisorTitle={advisorTitle}
|
||||||
|
/>
|
||||||
|
</AbsoluteFill>
|
||||||
)}
|
)}
|
||||||
</Sequence>
|
</Sequence>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 191 KiB |