2026/1/22更新
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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:
|
||||
print(f" ⏭️ Keeping original audio for video segment {segment_id}")
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
||||
# 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"
|
||||
elif "audioPath" in segment:
|
||||
del segment["audioPath"]
|
||||
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -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": "キーワード"
|
||||
|
|
|
|||
|
|
@ -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...")
|
||||
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...")
|
||||
|
|
|
|||
|
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) => {
|
||||
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,90 +385,106 @@ 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
|
||||
}}>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{/* Left Column (320px) - Reserved for Key Insights */}
|
||||
<div style={{ width: 320, flexShrink: 0 }} />
|
||||
|
||||
{/* 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
|
||||
{/* 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
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
) : (
|
||||
<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) && (
|
||||
<FloatingInsight
|
||||
text={segment.insight || segment.text.split("。")[0] + "。"}
|
||||
delay={40}
|
||||
durationInFrames={segment.durationInFrames}
|
||||
/>
|
||||
<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,15 +494,22 @@ 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" && (
|
||||
<Avatar
|
||||
isSpeaking={segment.type === "narration" || segment.type === "chart_intro"}
|
||||
position="bottom-right"
|
||||
size={200}
|
||||
advisorName={advisorName}
|
||||
advisorTitle={advisorTitle}
|
||||
/>
|
||||
<AbsoluteFill style={{ zIndex: 100, pointerEvents: "none" }}>
|
||||
<Avatar
|
||||
isSpeaking={segment.type === "narration" || segment.type === "chart_intro"}
|
||||
position="bottom-right"
|
||||
size={200}
|
||||
advisorName={advisorName}
|
||||
advisorTitle={advisorTitle}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
</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 |